From 800e8ebf44a5e84a278902590c794eb48288f35d Mon Sep 17 00:00:00 2001 From: ayushya Date: Mon, 28 Sep 2026 12:08:23 -0400 Subject: [PATCH] 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. --- .../context/design-system/DESIGN_SYSTEM.md | 8 +- lib/main.dart | 16 +- lib/widgets/noo/core/noo_fab.dart | 48 +++++- lib/widgets/noo/lists/noo_tab_order_row.dart | 14 +- lib/widgets/noo/nav/noo_bottom_bar.dart | 161 ++++++++++++------ lib/widgets/settings/settings_tabs.dart | 61 +++++-- lib/widgets/shell/shell_common.dart | 14 +- test/widgets/noo/core_test.dart | 142 ++++++++++++--- test/widgets/noo/nav_test.dart | 141 +++++++++++++++ 9 files changed, 488 insertions(+), 117 deletions(-) create mode 100644 test/widgets/noo/nav_test.dart diff --git a/.claude/context/design-system/DESIGN_SYSTEM.md b/.claude/context/design-system/DESIGN_SYSTEM.md index e5a2383..0f6cd9d 100644 --- a/.claude/context/design-system/DESIGN_SYSTEM.md +++ b/.claude/context/design-system/DESIGN_SYSTEM.md @@ -108,7 +108,11 @@ Use **Lucide** (pinned to `lucide-static@0.460.0`) at a 1.8 stroke. Sizes are 14 **FAB** (Android only) - An extended pill: 56px tall, accent fill, labelled "Upload". - Sits 16px from the right and bottom edges of the content area. -- Shows only on Files and Photos. +- Stays mounted on every tab; it's only the extended label that's tied to + Files/Photos. Elsewhere it collapses to an icon-only 56px circle (still + tappable - it always targets the Files tab's current folder), animating + the width/label change rather than the button popping fully in/out as + the Scaffold's default FAB transition would on every tab switch. **Chip** - A pill, 32–34px tall, 12px horizontal padding, Label 13–14. @@ -254,7 +258,7 @@ Mobile always has **5 pinned tabs in the bottom bar**. The rest go in the drawer |---|---|---| | Status bar | 54px, Dynamic Island | 40px | | Top bar | 44px row: `menu` on the left; `plus` and avatar on the right (all accent-text). Then a 34px large title, then the search field. | 64px: `menu`, 22px title, `search`, avatar | -| Bottom bar | Surface fill, 1px top line. Icon 24 above a 10px label. Active: accent-text. Idle: fg-3. 34px home indicator below. | Surface fill, 80px. The active icon sits in a 56×32 accent-soft pill with a 12px label. 20px gesture bar below. | +| Bottom bar | Surface fill, 1px top line. Icon 24 above a 10px label. Active: accent-text. Idle: fg-3. 34px home indicator below. | Surface fill, 80px. Icon 24 above a 12px label (label space is always reserved, just invisible when idle, so nothing shifts on selection). The active icon sits inside a 56×32 accent-soft pill that slides between tabs as one shared indicator, rather than popping in/out per tab. 20px gesture bar below. | | Upload | `plus` in the nav bar | Extended FAB | | Overflow icon | `ellipsis` | `ellipsis-vertical` | | Settings | Pushed screen with a "‹ Files" back button and a large title. No bottom bar. | Pushed screen with a back arrow and a title. No bottom bar. | diff --git a/lib/main.dart b/lib/main.dart index 93d27ec..5adeb99 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -74,8 +74,7 @@ void main() { SyncStatusController(context.read(), context.read()), ), ChangeNotifierProvider( - create: (context) => - PhotosController(context.read(), context.read()), + create: (context) => PhotosController(context.read(), context.read()), ), ChangeNotifierProvider( create: (context) => @@ -481,9 +480,16 @@ class _MainShellViewState extends State { : null, drawer: pickRequest == null ? const AppDrawer() : null, // Android-only extended Upload FAB - iOS uses the top bar's `plus` - // instead (see AppTopBar). - floatingActionButton: canUpload && navStyle == NooNavStyle.android - ? NooFab(onTap: () => showCreateMenu(context)) + // instead (see AppTopBar). Stays mounted across every tab (picking + // aside) and collapses to an icon-only circle off Files/Photos, + // rather than the Scaffold popping it fully in/out on every tab + // switch - see NooFab's [collapsed]. + floatingActionButton: + pickRequest == null && navStyle == NooNavStyle.android + ? NooFab( + collapsed: !canUpload, + onTap: () => showCreateMenu(context), + ) : null, body: tabStack, bottomNavigationBar: BottomNavBar( diff --git a/lib/widgets/noo/core/noo_fab.dart b/lib/widgets/noo/core/noo_fab.dart index 4d79175..985313e 100644 --- a/lib/widgets/noo/core/noo_fab.dart +++ b/lib/widgets/noo/core/noo_fab.dart @@ -5,12 +5,24 @@ import '../../../theme/design_tokens.dart'; /// Android-only extended Upload pill, bottom-right of Files and Photos (Noo /// Design System project, `components/core/FAB.jsx`). 56px tall, accent /// fill - iOS uses a `plus` in the nav bar instead (not yet built). +/// +/// [collapsed] shrinks it to an icon-only circle (no label) on tabs that +/// don't support upload, so the button stays in place and resizes as the +/// user switches tabs instead of the Scaffold popping it fully in/out - +/// the label's width animates away rather than the FAB disappearing. class NooFab extends StatelessWidget { final String label; final IconData icon; final VoidCallback? onTap; + final bool collapsed; - const NooFab({super.key, this.label = 'Upload', this.icon = LucideIcons.plus, this.onTap}); + const NooFab({ + super.key, + this.label = 'Upload', + this.icon = LucideIcons.plus, + this.onTap, + this.collapsed = false, + }); @override Widget build(BuildContext context) { @@ -21,9 +33,16 @@ class NooFab extends StatelessWidget { child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(NooRadii.pill), - child: Container( + child: AnimatedContainer( + duration: NooMotion.base, + curve: NooMotion.ease, height: 56, - padding: const EdgeInsets.fromLTRB(16, 0, 20, 0), + padding: EdgeInsetsDirectional.fromSTEB( + 16, + 0, + collapsed ? 16 : 20, + 0, + ), // No `alignment:` here - Container without an explicit width // wraps its child in an Align when alignment is set, and Align // EXPANDS to fill all available bounded space (not just the @@ -36,8 +55,27 @@ class NooFab extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 24, color: Colors.white), - const SizedBox(width: 10), - Text(label, style: NooText.button.copyWith(color: Colors.white)), + ClipRect( + child: AnimatedSize( + duration: NooMotion.base, + curve: NooMotion.ease, + alignment: AlignmentDirectional.centerStart, + child: collapsed + ? const SizedBox(height: 24) + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 10), + Text( + label, + style: NooText.button.copyWith( + color: Colors.white, + ), + ), + ], + ), + ), + ), ], ), ), diff --git a/lib/widgets/noo/lists/noo_tab_order_row.dart b/lib/widgets/noo/lists/noo_tab_order_row.dart index 0c41c6a..d1f276e 100644 --- a/lib/widgets/noo/lists/noo_tab_order_row.dart +++ b/lib/widgets/noo/lists/noo_tab_order_row.dart @@ -11,24 +11,36 @@ class NooTabOrderRow extends StatelessWidget { final bool pinned; final VoidCallback? onTogglePin; + /// This row's index inside its `ReorderableListView` - when set, only the + /// grip icon starts a drag (`ReorderableDragStartListener` wraps just the + /// icon, not the whole row), so a vertical drag/scroll starting anywhere + /// else on the row reaches the surrounding settings page's own scroll + /// view instead of being grabbed as a reorder. Null renders a plain, + /// non-interactive grip icon (e.g. a standalone preview/test). + final int? dragIndex; + const NooTabOrderRow({ super.key, required this.icon, required this.label, this.pinned = false, this.onTogglePin, + this.dragIndex, }); @override Widget build(BuildContext context) { final colors = context.nooColors; + final grip = Icon(LucideIcons.gripVertical, size: 18, color: colors.fg3); return Container( height: 52, padding: const EdgeInsets.symmetric(horizontal: 10), color: colors.surface, child: Row( children: [ - Icon(LucideIcons.gripVertical, size: 18, color: colors.fg3), + dragIndex == null + ? grip + : ReorderableDragStartListener(index: dragIndex!, child: grip), const SizedBox(width: 12), Icon(icon, size: 20, color: colors.fg2), const SizedBox(width: 12), diff --git a/lib/widgets/noo/nav/noo_bottom_bar.dart b/lib/widgets/noo/nav/noo_bottom_bar.dart index 44b98bb..eafe993 100644 --- a/lib/widgets/noo/nav/noo_bottom_bar.dart +++ b/lib/widgets/noo/nav/noo_bottom_bar.dart @@ -4,6 +4,12 @@ import 'noo_nav_style.dart'; export 'noo_nav_style.dart'; +/// Android indicator geometry, shared between the sliding pill and each +/// item's own icon box so they line up exactly. +const double _kAndroidPillTop = 14; +const double _kAndroidPillWidth = 56; +const double _kAndroidPillHeight = 32; + /// The mobile bottom bar (DESIGN_SYSTEM.md 3, "Mobile"; `iNav`/`aNav` in /// `Mobile Screen.dc.html`). Shows the 5 pinned tabs - which ones, and in /// what order, is the caller's business; this only draws [destinations]. @@ -11,9 +17,11 @@ export 'noo_nav_style.dart'; /// - [NooNavStyle.ios]: surface fill, 1px top `line`, a 50px row (8px top /// padding) of icon 24 over a 10px label. Active: accent-text, 600. /// Idle: fg-3, 500. No ripple. -/// - [NooNavStyle.android]: surface fill, 80px. The active icon sits in a -/// 56x32 accent-soft pill (accent-text icon); label is 12px, fg-1/600 -/// active and fg-2/500 idle (idle icon fg-2). +/// - [NooNavStyle.android]: surface fill, 80px. Every tab shows its 24px +/// icon over a 12px label (label only visible - not removed from layout, +/// so the row never resizes - once selected). The active icon sits in a +/// 56x32 accent-soft pill that slides between tabs as selection moves, +/// rather than popping in/out on the destination item itself. /// /// The home indicator / gesture bar area below the row comes from the /// bottom safe-area inset rather than a fixed 34/20px spacer, so it's @@ -37,25 +45,7 @@ class NooBottomBar extends StatelessWidget { final colors = context.nooColors; final ios = style == NooNavStyle.ios; - final row = Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - for (var i = 0; i < destinations.length; i++) - Expanded( - child: ios - ? _IosItem( - destination: destinations[i], - selected: i == selectedIndex, - onTap: () => onSelected(i), - ) - : _AndroidItem( - destination: destinations[i], - selected: i == selectedIndex, - onTap: () => onSelected(i), - ), - ), - ], - ); + final row = ios ? _buildIosRow() : _buildAndroidRow(colors); return Container( decoration: BoxDecoration( @@ -68,6 +58,63 @@ class NooBottomBar extends StatelessWidget { ), ); } + + Widget _buildIosRow() { + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var i = 0; i < destinations.length; i++) + Expanded( + child: _IosItem( + destination: destinations[i], + selected: i == selectedIndex, + onTap: () => onSelected(i), + ), + ), + ], + ); + } + + Widget _buildAndroidRow(NooColors colors) { + return LayoutBuilder( + builder: (context, constraints) { + final itemWidth = constraints.maxWidth / destinations.length; + return Stack( + children: [ + AnimatedPositioned( + duration: NooMotion.base, + curve: NooMotion.ease, + top: _kAndroidPillTop, + left: + itemWidth * selectedIndex + + (itemWidth - _kAndroidPillWidth) / 2, + width: _kAndroidPillWidth, + height: _kAndroidPillHeight, + child: DecoratedBox( + decoration: BoxDecoration( + color: colors.accentSoft, + borderRadius: BorderRadius.circular(NooRadii.pill), + ), + ), + ), + Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var i = 0; i < destinations.length; i++) + Expanded( + child: _AndroidItem( + destination: destinations[i], + selected: i == selectedIndex, + onTap: () => onSelected(i), + ), + ), + ], + ), + ], + ); + }, + ); + } } class _IosItem extends StatelessWidget { @@ -117,6 +164,11 @@ class _IosItem extends StatelessWidget { } } +/// Icon (over the shared sliding pill, drawn separately by [NooBottomBar]) +/// with its label always below it - the label's space is reserved +/// whether or not it's showing (only its opacity changes), so the icon +/// never shifts vertically as selection changes, and stays aligned with +/// the pill's fixed [_kAndroidPillTop]/[_kAndroidPillHeight]. class _AndroidItem extends StatelessWidget { final NooNavDestination destination; final bool selected; @@ -134,43 +186,42 @@ class _AndroidItem extends StatelessWidget { return Semantics( button: true, selected: selected, - // No ripple: the animated pill is the press/selection feedback. + label: destination.label, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: onTap, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - AnimatedContainer( - duration: NooMotion.fast, - curve: NooMotion.ease, - width: 56, - height: 32, - alignment: Alignment.center, - decoration: BoxDecoration( - color: selected - ? colors.accentSoft - : colors.accentSoft.withValues(alpha: 0), - borderRadius: BorderRadius.circular(NooRadii.pill), - ), - child: Icon( - destination.icon, - size: 24, - color: selected ? colors.accentText : colors.fg2, - ), - ), - const SizedBox(height: 4), - Text( - destination.label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: (selected ? NooText.navLabelActive : NooText.navLabel) - .copyWith( - height: 1, - color: selected ? colors.fg1 : colors.fg2, + child: Padding( + padding: const EdgeInsets.only(top: _kAndroidPillTop), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + SizedBox( + height: _kAndroidPillHeight, + child: Center( + child: Icon( + destination.icon, + size: 24, + color: selected ? colors.accentText : colors.fg2, ), - ), - ], + ), + ), + const SizedBox(height: 4), + AnimatedOpacity( + duration: NooMotion.base, + curve: NooMotion.ease, + opacity: selected ? 1 : 0, + child: Text( + destination.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.navLabelActive.copyWith( + height: 1, + color: colors.fg1, + ), + ), + ), + ], + ), ), ), ); diff --git a/lib/widgets/settings/settings_tabs.dart b/lib/widgets/settings/settings_tabs.dart index f95ea42..94ef147 100644 --- a/lib/widgets/settings/settings_tabs.dart +++ b/lib/widgets/settings/settings_tabs.dart @@ -15,8 +15,13 @@ import 'settings_section.dart'; class SettingsTabsSection extends StatelessWidget { const SettingsTabsSection({super.key}); - void _openDefaultTabPicker(BuildContext context, SettingsController settings) { - final visible = settings.tabOrder.where((t) => !settings.hiddenTabs.contains(t)).toList(); + void _openDefaultTabPicker( + BuildContext context, + SettingsController settings, + ) { + final visible = settings.tabOrder + .where((t) => !settings.hiddenTabs.contains(t)) + .toList(); showSettingsPicker( context, title: 'Default tab', @@ -26,7 +31,11 @@ class SettingsTabsSection extends StatelessWidget { icon: tab.icon, label: Text(tab.label), trailing: tab == settings.defaultTab - ? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText) + ? Icon( + LucideIcons.check, + size: 18, + color: context.nooColors.accentText, + ) : null, onTap: () { settings.setDefaultTab(tab); @@ -37,7 +46,12 @@ class SettingsTabsSection extends StatelessWidget { ); } - void _handleTogglePin(BuildContext context, SettingsController settings, AppTab tab, bool pinned) { + void _handleTogglePin( + BuildContext context, + SettingsController settings, + AppTab tab, + bool pinned, + ) { // setTabHidden's `hidden` argument is the *new* hidden state, which is // just the tab's current pinned (= visible) state flipped. final error = settings.setTabHidden(tab, pinned); @@ -66,7 +80,9 @@ class SettingsTabsSection extends StatelessWidget { NooSettingsRow( icon: LucideIcons.arrowUpDown, label: const Text('Tap tab to scroll to top'), - subtitle: const Text('Tapping the current bottom bar tab scrolls its list back to the top'), + subtitle: const Text( + 'Tapping the current bottom bar tab scrolls its list back to the top', + ), trailing: NooToggle( checked: settings.tapTabToScrollTop, onChanged: settings.setTapTabToScrollTop, @@ -83,15 +99,21 @@ class SettingsTabsSection extends StatelessWidget { tabs: settings.tabOrder, hiddenTabs: settings.hiddenTabs, flat: desktop, - onReorder: (oldIndex, newIndex) => _handleReorder(settings, oldIndex, newIndex), - onTogglePin: (tab, pinned) => _handleTogglePin(context, settings, tab, pinned), + onReorder: (oldIndex, newIndex) => + _handleReorder(settings, oldIndex, newIndex), + onTogglePin: (tab, pinned) => + _handleTogglePin(context, settings, tab, pinned), ); if (!desktop) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - SettingsSection(title: 'Tabs', subtitle: _subtitle, children: optionRows), + SettingsSection( + title: 'Tabs', + subtitle: _subtitle, + children: optionRows, + ), const SizedBox(height: NooSpace.md), orderList, ], @@ -164,21 +186,24 @@ class _TabOrderList extends StatelessWidget { return Padding( key: ValueKey(tab), padding: EdgeInsets.only(top: index > 0 ? 1 : 0), - child: ReorderableDragStartListener( - index: index, - child: NooTabOrderRow( - icon: tab.icon, - label: tab.label, - pinned: pinned, - onTogglePin: () => onTogglePin(tab, pinned), - ), + child: NooTabOrderRow( + icon: tab.icon, + label: tab.label, + pinned: pinned, + onTogglePin: () => onTogglePin(tab, pinned), + dragIndex: index, ), ); }, ); return ClipRRect( - borderRadius: BorderRadius.circular(flat ? NooRadii.input : NooRadii.card), - child: DecoratedBox(decoration: BoxDecoration(color: colors.line), child: list), + borderRadius: BorderRadius.circular( + flat ? NooRadii.input : NooRadii.card, + ), + child: DecoratedBox( + decoration: BoxDecoration(color: colors.line), + child: list, + ), ); } } diff --git a/lib/widgets/shell/shell_common.dart b/lib/widgets/shell/shell_common.dart index 9059680..77b25d4 100644 --- a/lib/widgets/shell/shell_common.dart +++ b/lib/widgets/shell/shell_common.dart @@ -211,10 +211,10 @@ class _AccountSwitchRow extends StatelessWidget { } } -/// The current user's avatar in the mobile top bar. Tap opens Settings; -/// swiping up/down on it cycles to the next/previous saved account -/// immediately, with no confirmation - a shortcut alongside the full -/// switcher (drawer chevron, Settings' account list). +/// The current user's avatar in the mobile top bar. Tap opens the accounts +/// sheet ([showAccountSwitcher]); swiping up/down on it cycles to the +/// next/previous saved account immediately, with no confirmation - a +/// shortcut alongside the full switcher. class ShellAvatarButton extends StatelessWidget { final double hitBox; @@ -246,17 +246,17 @@ class ShellAvatarButton extends StatelessWidget { Widget build(BuildContext context) { final session = context.watch(); return Tooltip( - message: 'Settings', + message: 'Accounts', child: Semantics( button: true, - label: 'Settings', + label: 'Accounts', child: GestureDetector( onVerticalDragEnd: (details) => _handleVerticalSwipe(context, details), child: SizedBox.square( dimension: hitBox, child: InkResponse( - onTap: () => openSettings(context), + onTap: () => showAccountSwitcher(context), radius: hitBox / 2, child: Center( child: NooAvatar( diff --git a/test/widgets/noo/core_test.dart b/test/widgets/noo/core_test.dart index 0720492..dc718f1 100644 --- a/test/widgets/noo/core_test.dart +++ b/test/widgets/noo/core_test.dart @@ -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(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(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(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(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( + 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(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)), const Size(78, 34)); + expect( + tester.getSize(find.byType(NooSegmentedControl)), + 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(find.byType(AnimatedContainer)).decoration! - as BoxDecoration) - .color!; + Color track() => + (tester + .widget(find.byType(AnimatedContainer)) + .decoration! + as BoxDecoration) + .color!; expect(track(), c.surface3); await tester.tap(find.byType(NooToggle)); diff --git a/test/widgets/noo/nav_test.dart b/test/widgets/noo/nav_test.dart new file mode 100644 index 0000000..3b5df93 --- /dev/null +++ b/test/widgets/noo/nav_test.dart @@ -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( + 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)); + }); + }); +}