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:
+118
-24
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user