Fix five Android nav/shell UX issues

- Bottom bar: labels now sit below each icon instead of beside it, and
  the active-tab pill is one shared indicator that slides between tabs
  instead of popping in/out per item.
- Upload FAB stays mounted across every tab and collapses to an
  icon-only circle off Files/Photos, instead of the Scaffold popping it
  fully in/out on every tab switch.
- Tapping the top-bar avatar now opens the accounts sheet instead of
  jumping straight to Settings.
- Settings tab reordering only starts a drag from the grip icon now -
  previously the whole row claimed vertical drags, so scrolling the
  section could trigger an accidental reorder.
This commit is contained in:
2026-09-28 12:08:23 -04:00
parent d2c34b000f
commit 800e8ebf44
9 changed files with 488 additions and 117 deletions
+118 -24
View File
@@ -18,17 +18,39 @@ void main() {
setUpNooTests();
group('NooAvatar', () {
testNooWidgets('current user uses accent-soft/accent-text', (tester, theme, c) async {
await pumpNoo(tester, const NooAvatar(initials: 'AY', current: true), theme: theme);
testNooWidgets('current user uses accent-soft/accent-text', (
tester,
theme,
c,
) async {
await pumpNoo(
tester,
const NooAvatar(initials: 'AY', current: true),
theme: theme,
);
expect(find.text('AY'), findsOneWidget);
expect(decorationOf(tester, find.text('AY')).color, c.accentSoft);
expect(tester.widget<Text>(find.text('AY')).style!.color, c.accentText);
});
testNooWidgets('palette avatar uses fixed dark text', (tester, theme, c) async {
await pumpNoo(tester, NooAvatar(initials: 'JD', color: NooColors.avatarPalette[3]), theme: theme);
expect(decorationOf(tester, find.text('JD')).color, NooColors.avatarPalette[3]);
expect(tester.widget<Text>(find.text('JD')).style!.color, NooColors.avatarTextColor);
testNooWidgets('palette avatar uses fixed dark text', (
tester,
theme,
c,
) async {
await pumpNoo(
tester,
NooAvatar(initials: 'JD', color: NooColors.avatarPalette[3]),
theme: theme,
);
expect(
decorationOf(tester, find.text('JD')).color,
NooColors.avatarPalette[3],
);
expect(
tester.widget<Text>(find.text('JD')).style!.color,
NooColors.avatarTextColor,
);
});
testWidgets('icon avatar and size', (tester) async {
@@ -62,7 +84,11 @@ void main() {
var taps = 0;
await pumpNoo(
tester,
NooButton(icon: LucideIcons.upload, onTap: () => taps++, child: const Text('Upload')),
NooButton(
icon: LucideIcons.upload,
onTap: () => taps++,
child: const Text('Upload'),
),
theme: theme,
);
expect(decorationOf(tester, find.text('Upload')).color, c.accent);
@@ -73,13 +99,18 @@ void main() {
testWidgets('disabled ignores taps', (tester) async {
var taps = 0;
await pumpNoo(tester, NooButton(disabled: true, onTap: () => taps++, child: const Text('No')));
await pumpNoo(
tester,
NooButton(disabled: true, onTap: () => taps++, child: const Text('No')),
);
await tester.tap(find.text('No'), warnIfMissed: false);
await tester.pumpAndSettle();
expect(taps, 0);
});
testWidgets('shrink-wraps unless fullWidth, heights follow size', (tester) async {
testWidgets('shrink-wraps unless fullWidth, heights follow size', (
tester,
) async {
await pumpNoo(
tester,
const Column(
@@ -118,14 +149,22 @@ void main() {
});
group('NooChip', () {
testNooWidgets('selected vs idle fill, onTap fires', (tester, theme, c) async {
testNooWidgets('selected vs idle fill, onTap fires', (
tester,
theme,
c,
) async {
var taps = 0;
await pumpNoo(
tester,
Column(
mainAxisSize: MainAxisSize.min,
children: [
NooChip(selected: true, onTap: () => taps++, child: const Text('Name')),
NooChip(
selected: true,
onTap: () => taps++,
child: const Text('Name'),
),
const NooChip(trailing: NooChipTrailing.menu, child: Text('Type')),
],
),
@@ -138,9 +177,13 @@ void main() {
});
testWidgets('outline chip is 32px with a line border', (tester) async {
await pumpNoo(tester, const NooChip(outline: true, child: Text('Modified')));
await pumpNoo(
tester,
const NooChip(outline: true, child: Text('Modified')),
);
expect(tester.getSize(find.byType(NooChip)).height, 32);
final border = decorationOf(tester, find.text('Modified')).border as Border;
final border =
decorationOf(tester, find.text('Modified')).border as Border;
expect(border.top.color, NooColors.light.line);
});
});
@@ -155,12 +198,42 @@ void main() {
await tester.tap(find.byType(NooFab));
expect(taps, 1);
});
testNooWidgets('collapsed hides the label and shrinks to a circle', (
tester,
theme,
c,
) async {
var taps = 0;
await pumpNoo(
tester,
NooFab(collapsed: true, onTap: () => taps++),
theme: theme,
);
await tester.pumpAndSettle();
expect(find.text('Upload'), findsNothing);
final size = tester.getSize(find.byType(NooFab));
expect(size.height, 56);
expect(size.width, 56);
await tester.tap(find.byType(NooFab));
expect(taps, 1);
});
});
group('NooProgressBar', () {
testNooWidgets('clamps value and uses track/fill tokens', (tester, theme, c) async {
await pumpNoo(tester, const SizedBox(width: 200, child: NooProgressBar(value: 1.7)), theme: theme);
final bar = tester.widget<LinearProgressIndicator>(find.byType(LinearProgressIndicator));
testNooWidgets('clamps value and uses track/fill tokens', (
tester,
theme,
c,
) async {
await pumpNoo(
tester,
const SizedBox(width: 200, child: NooProgressBar(value: 1.7)),
theme: theme,
);
final bar = tester.widget<LinearProgressIndicator>(
find.byType(LinearProgressIndicator),
);
expect(bar.value, 1.0);
expect(bar.backgroundColor, c.surface3);
expect(bar.valueColor!.value, c.accent);
@@ -169,11 +242,18 @@ void main() {
});
group('NooSearchField', () {
testNooWidgets('placeholder, typing, surface fill', (tester, theme, c) async {
testNooWidgets('placeholder, typing, surface fill', (
tester,
theme,
c,
) async {
String? typed;
await pumpNoo(
tester,
SizedBox(width: 260, child: NooSearchField(onSurface: true, onChanged: (v) => typed = v)),
SizedBox(
width: 260,
child: NooSearchField(onSurface: true, onChanged: (v) => typed = v),
),
theme: theme,
);
expect(find.text('Search'), findsOneWidget);
@@ -198,7 +278,11 @@ void main() {
);
}
testNooWidgets('tapping a segment moves the active fill', (tester, theme, c) async {
testNooWidgets('tapping a segment moves the active fill', (
tester,
theme,
c,
) async {
await pumpNoo(tester, host(), theme: theme);
expect(decorationOf(tester, find.text('List')).color, c.accentSoft);
expect(tester.widget<Text>(find.text('Grid')).style!.color, c.fg2);
@@ -224,12 +308,19 @@ void main() {
);
expect(find.byType(Text), findsNothing);
// 2 x 36 wide segments + 3px track padding each side.
expect(tester.getSize(find.byType(NooSegmentedControl<int>)), const Size(78, 34));
expect(
tester.getSize(find.byType(NooSegmentedControl<int>)),
const Size(78, 34),
);
});
});
group('NooToggle', () {
testNooWidgets('tapping flips state and track colour', (tester, theme, c) async {
testNooWidgets('tapping flips state and track colour', (
tester,
theme,
c,
) async {
var on = false;
await pumpNoo(
tester,
@@ -240,9 +331,12 @@ void main() {
theme: theme,
);
expect(tester.getSize(find.byType(NooToggle)), const Size(48, 28));
Color track() => (tester.widget<AnimatedContainer>(find.byType(AnimatedContainer)).decoration!
as BoxDecoration)
.color!;
Color track() =>
(tester
.widget<AnimatedContainer>(find.byType(AnimatedContainer))
.decoration!
as BoxDecoration)
.color!;
expect(track(), c.surface3);
await tester.tap(find.byType(NooToggle));
+141
View File
@@ -0,0 +1,141 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:noo/theme/design_tokens.dart';
import 'package:noo/widgets/noo/nav/noo_bottom_bar.dart';
import 'noo_test_utils.dart';
const _destinations = [
NooNavDestination(icon: LucideIcons.folder, label: 'Files'),
NooNavDestination(icon: LucideIcons.images, label: 'Photos'),
NooNavDestination(icon: LucideIcons.star, label: 'Favorites'),
];
/// The single sliding accent-soft indicator `NooBottomBar` draws behind the
/// Android row - there should only ever be one, never a per-item pill.
Finder _indicator(WidgetTester tester, NooColors c) => find.byWidgetPredicate(
(w) =>
w is DecoratedBox &&
w.decoration is BoxDecoration &&
(w.decoration as BoxDecoration).color == c.accentSoft,
);
void main() {
setUpNooTests();
group('NooBottomBar', () {
testNooWidgets('android: label sits below the icon, not beside it', (
tester,
theme,
c,
) async {
await pumpNoo(
tester,
SizedBox(
width: 360,
height: 80,
child: NooBottomBar(
style: NooNavStyle.android,
destinations: _destinations,
selectedIndex: 0,
onSelected: (_) {},
),
),
theme: theme,
);
final iconCenter = tester.getCenter(find.byIcon(LucideIcons.folder));
final labelCenter = tester.getCenter(find.text('Files'));
expect(labelCenter.dy, greaterThan(iconCenter.dy));
expect((labelCenter.dx - iconCenter.dx).abs(), lessThan(1));
});
testNooWidgets(
'android: exactly one indicator, it slides to the tapped tab',
(tester, theme, c) async {
var selected = 0;
await pumpNoo(
tester,
StatefulBuilder(
builder: (context, setState) => SizedBox(
width: 360,
height: 80,
child: NooBottomBar(
style: NooNavStyle.android,
destinations: _destinations,
selectedIndex: selected,
onSelected: (i) => setState(() => selected = i),
),
),
),
theme: theme,
);
expect(_indicator(tester, c), findsOneWidget);
final startX = tester.getTopLeft(_indicator(tester, c)).dx;
await tester.tap(find.text('Favorites'));
await tester.pumpAndSettle();
expect(_indicator(tester, c), findsOneWidget);
expect(
tester.getTopLeft(_indicator(tester, c)).dx,
greaterThan(startX),
);
},
);
testNooWidgets('android: idle labels stay laid out but invisible', (
tester,
theme,
c,
) async {
await pumpNoo(
tester,
SizedBox(
width: 360,
height: 80,
child: NooBottomBar(
style: NooNavStyle.android,
destinations: _destinations,
selectedIndex: 0,
onSelected: (_) {},
),
),
theme: theme,
);
// Idle labels are still in the tree (fixed row height, no jump when
// selection changes) - just invisible.
final opacity = tester.widget<AnimatedOpacity>(
find.ancestor(
of: find.text('Photos'),
matching: find.byType(AnimatedOpacity),
),
);
expect(opacity.opacity, 0);
});
testNooWidgets('ios: unchanged icon-above-label layout, no indicator', (
tester,
theme,
c,
) async {
await pumpNoo(
tester,
SizedBox(
width: 360,
height: 50,
child: NooBottomBar(
style: NooNavStyle.ios,
destinations: _destinations,
selectedIndex: 1,
onSelected: (_) {},
),
),
theme: theme,
);
expect(_indicator(tester, c), findsNothing);
final iconCenter = tester.getCenter(find.byIcon(LucideIcons.images));
final labelCenter = tester.getCenter(find.text('Photos'));
expect(labelCenter.dy, greaterThan(iconCenter.dy));
});
});
}