diff --git a/.claude/context/design-system/DESIGN_SYSTEM.md b/.claude/context/design-system/DESIGN_SYSTEM.md index 276c04b..2ddead3 100644 --- a/.claude/context/design-system/DESIGN_SYSTEM.md +++ b/.claude/context/design-system/DESIGN_SYSTEM.md @@ -273,6 +273,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. 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. | +| Bottom bar - floating (Settings → Appearance → "Bottom bar", both platforms) | Same content, inset 16px from both side edges and clear of the bottom safe area instead of edge to edge, radius 28 (the sheet-top/drawer-edge radius) rather than square corners. No shadow (product UI stays flat - see 1.4); a 1px `line` border stands in for elevation instead. Row height drops slightly (64/72 vs 50/80) to suit. Android's idle tabs drop their reserved label space in this mode - the icon just centers in the button and renders a touch bigger (27 vs 24px) - rather than sitting high with a gap held open under it. `Scaffold.extendBody: true` while floating, so the body scrolls behind the bar's transparent margin instead of stopping short of it. | | 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/.claude/context/styling.md b/.claude/context/styling.md index dc75393..b541e87 100644 --- a/.claude/context/styling.md +++ b/.claude/context/styling.md @@ -191,7 +191,12 @@ blocks are noted where they matter: opacity/blur knob - flat surfaces per the design system), grid tiles are `NooGridCard`, swipe actions are `NooSwipeAction`, and per-item sync status is `NooFileRow`/`NooFileTableRow`'s built-in `NooStatusIcon` list - instead of a corner badge. + instead of a corner badge. `NooBottomBar` later gained its own, unrelated + `NooBottomBarStyle.floating` (Settings → Appearance → "Bottom bar") - + don't confuse the two: this one is still flat/non-blurred, just inset + with a `line` border instead of edge to edge (no opacity/blur knob + either). The host `Scaffold` needs `extendBody: true` while it's active + (`main.dart` already wires this off `SettingsController.bottomBarStyle`). - [`SyncedHeaderScaffold`](../../lib/widgets/synced_header_scaffold.dart) — the pull-to-sync `CustomScrollView` header with the persistent sync-status chip and pull-to-refresh gesture/spinner. Every screen (including diff --git a/lib/main.dart b/lib/main.dart index 5adeb99..256ba94 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -472,6 +472,7 @@ class _MainShellViewState extends State { ); } else { final navStyle = NooLayout.navStyle(context); + final bottomBarStyle = settings.bottomBarStyle; scaffold = Scaffold( backgroundColor: colors.bg, drawerScrimColor: colors.scrim, @@ -479,6 +480,10 @@ class _MainShellViewState extends State { ? AppTopBar(style: navStyle, tab: selectedTab) : null, drawer: pickRequest == null ? const AppDrawer() : null, + // Floating needs the body to draw behind the bar's own transparent + // margin (see NooBottomBarStyle's doc comment) instead of stopping + // short of it like attached does. + extendBody: bottomBarStyle == NooBottomBarStyle.floating, // Android-only extended Upload FAB - iOS uses the top bar's `plus` // instead (see AppTopBar). Stays mounted across every tab (picking // aside) and collapses to an icon-only circle off Files/Photos, @@ -494,6 +499,7 @@ class _MainShellViewState extends State { body: tabStack, bottomNavigationBar: BottomNavBar( style: navStyle, + barStyle: bottomBarStyle, tabs: pinnedTabs, selectedIndex: pinnedIndex, onDestinationSelected: (index) { diff --git a/lib/providers/settings_controller.dart b/lib/providers/settings_controller.dart index d5d247a..b7bfec4 100644 --- a/lib/providers/settings_controller.dart +++ b/lib/providers/settings_controller.dart @@ -3,6 +3,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../models/app_tab.dart'; import '../models/selection_action.dart'; import '../theme/app_theme.dart'; +import '../widgets/noo/nav/noo_nav_style.dart'; /// What swiping a Files list-view item left/right does, user-configurable /// in Settings. @@ -30,6 +31,7 @@ class SettingsController extends ChangeNotifier { static const _prefDefaultTab = 'ui_default_tab'; static const _prefSwipeLeftAction = 'ui_swipe_left_action'; static const _prefSwipeRightAction = 'ui_swipe_right_action'; + static const _prefBottomBarStyle = 'ui_bottom_bar_style'; static const _prefAmoledDark = 'ui_amoled_dark'; static const _prefMediaProgressBarStyle = 'ui_media_progress_bar_style'; static const _prefTapTabToScrollTop = 'ui_tap_tab_to_scroll_top'; @@ -39,6 +41,7 @@ class SettingsController extends ChangeNotifier { Color _seedColor = AppTheme.defaultAccent; ThemeMode _themeMode = ThemeMode.system; + NooBottomBarStyle _bottomBarStyle = NooBottomBarStyle.attached; bool _useDynamicColor = true; bool _amoledDark = false; MediaProgressBarStyle _mediaProgressBarStyle = MediaProgressBarStyle.wavy; @@ -80,6 +83,7 @@ class SettingsController extends ChangeNotifier { Color get seedColor => _seedColor; ThemeMode get themeMode => _themeMode; + NooBottomBarStyle get bottomBarStyle => _bottomBarStyle; bool get useDynamicColor => _useDynamicColor; bool get amoledDark => _amoledDark; MediaProgressBarStyle get mediaProgressBarStyle => _mediaProgressBarStyle; @@ -115,6 +119,13 @@ class SettingsController extends ChangeNotifier { orElse: () => ThemeMode.system, ); } + final bottomBarStyleName = prefs.getString(_prefBottomBarStyle); + if (bottomBarStyleName != null) { + _bottomBarStyle = NooBottomBarStyle.values.firstWhere( + (s) => s.name == bottomBarStyleName, + orElse: () => _bottomBarStyle, + ); + } _useDynamicColor = prefs.getBool(_prefUseDynamicColor) ?? _useDynamicColor; _amoledDark = prefs.getBool(_prefAmoledDark) ?? _amoledDark; @@ -256,6 +267,13 @@ class SettingsController extends ChangeNotifier { _prefsFuture.then((p) => p.setString(_prefThemeMode, mode.name)); } + void setBottomBarStyle(NooBottomBarStyle style) { + if (_bottomBarStyle == style) return; + _bottomBarStyle = style; + notifyListeners(); + _prefsFuture.then((p) => p.setString(_prefBottomBarStyle, style.name)); + } + void setTabOrder(List order) { _tabOrder = order; notifyListeners(); diff --git a/lib/widgets/bottom_nav_bar.dart b/lib/widgets/bottom_nav_bar.dart index 9407d53..1da95de 100644 --- a/lib/widgets/bottom_nav_bar.dart +++ b/lib/widgets/bottom_nav_bar.dart @@ -9,6 +9,7 @@ import 'noo/nav/noo_bottom_bar.dart'; /// drawer's "More" list that isn't one of the pinned [tabs]. class BottomNavBar extends StatelessWidget { final NooNavStyle style; + final NooBottomBarStyle barStyle; final List tabs; final int selectedIndex; final ValueChanged onDestinationSelected; @@ -16,6 +17,7 @@ class BottomNavBar extends StatelessWidget { const BottomNavBar({ super.key, required this.style, + this.barStyle = NooBottomBarStyle.attached, required this.tabs, required this.selectedIndex, required this.onDestinationSelected, @@ -25,6 +27,7 @@ class BottomNavBar extends StatelessWidget { Widget build(BuildContext context) { return NooBottomBar( style: style, + barStyle: barStyle, destinations: [ for (final tab in tabs) NooNavDestination(icon: tab.icon, label: tab.label), diff --git a/lib/widgets/noo/nav/noo_bottom_bar.dart b/lib/widgets/noo/nav/noo_bottom_bar.dart index eafe993..807365f 100644 --- a/lib/widgets/noo/nav/noo_bottom_bar.dart +++ b/lib/widgets/noo/nav/noo_bottom_bar.dart @@ -5,8 +5,11 @@ 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; +/// item's own icon box so they line up exactly. Floating's own top offset +/// is shorter than attached's - its row is 8px shorter overall (72 vs 80) +/// and there's no edge-to-edge safe-area strip inside it eating into that. +const double _kAttachedPillTop = 14; +const double _kFloatingPillTop = 10; const double _kAndroidPillWidth = 56; const double _kAndroidPillHeight = 32; @@ -23,11 +26,21 @@ const double _kAndroidPillHeight = 32; /// 56x32 accent-soft pill that slides between tabs as selection moves, /// rather than popping in/out on the destination item itself. /// +/// [barStyle] (user-configurable in Settings, Appearance) picks between +/// that edge-to-edge [NooBottomBarStyle.attached] bar and +/// [NooBottomBarStyle.floating] - inset 16px from both side edges, 28px +/// corners, a 1px `line` border instead of a shadow (see that enum's own +/// doc comment for why). Floating's Android row is 8px shorter (72 vs 80) +/// and drops its idle tabs' reserved label space - with no label to leave +/// room for, an idle icon just centers in the whole button and renders a +/// touch bigger (27 vs 24px) instead of sitting high with a gap under it. /// 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 -/// right on every device. Meant for `Scaffold.bottomNavigationBar`. +/// right on every device. Meant for `Scaffold.bottomNavigationBar` - the +/// host `Scaffold` needs `extendBody: true` while floating. class NooBottomBar extends StatelessWidget { final NooNavStyle style; + final NooBottomBarStyle barStyle; final List destinations; final int selectedIndex; final ValueChanged onSelected; @@ -35,6 +48,7 @@ class NooBottomBar extends StatelessWidget { const NooBottomBar({ super.key, required this.style, + this.barStyle = NooBottomBarStyle.attached, required this.destinations, required this.selectedIndex, required this.onSelected, @@ -44,8 +58,31 @@ class NooBottomBar extends StatelessWidget { Widget build(BuildContext context) { final colors = context.nooColors; final ios = style == NooNavStyle.ios; + final floating = barStyle == NooBottomBarStyle.floating; - final row = ios ? _buildIosRow() : _buildAndroidRow(colors); + final row = ios + ? _buildIosRow() + : _buildAndroidRow(colors, floating: floating); + final barHeight = ios ? (floating ? 64.0 : 50.0) : (floating ? 72.0 : 80.0); + + if (floating) { + return SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Container( + height: barHeight, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: colors.surface, + border: Border.all(color: colors.line), + borderRadius: BorderRadius.circular(28), + ), + child: row, + ), + ), + ); + } return Container( decoration: BoxDecoration( @@ -54,7 +91,7 @@ class NooBottomBar extends StatelessWidget { ), child: SafeArea( top: false, - child: SizedBox(height: ios ? 50 : 80, child: row), + child: SizedBox(height: barHeight, child: row), ), ); } @@ -75,7 +112,8 @@ class NooBottomBar extends StatelessWidget { ); } - Widget _buildAndroidRow(NooColors colors) { + Widget _buildAndroidRow(NooColors colors, {required bool floating}) { + final pillTop = floating ? _kFloatingPillTop : _kAttachedPillTop; return LayoutBuilder( builder: (context, constraints) { final itemWidth = constraints.maxWidth / destinations.length; @@ -84,7 +122,7 @@ class NooBottomBar extends StatelessWidget { AnimatedPositioned( duration: NooMotion.base, curve: NooMotion.ease, - top: _kAndroidPillTop, + top: pillTop, left: itemWidth * selectedIndex + (itemWidth - _kAndroidPillWidth) / 2, @@ -105,6 +143,7 @@ class NooBottomBar extends StatelessWidget { child: _AndroidItem( destination: destinations[i], selected: i == selectedIndex, + floating: floating, onTap: () => onSelected(i), ), ), @@ -165,24 +204,69 @@ 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]. +/// with its label below it. Attached always reserves the label's space +/// (just invisible when idle, per [_kAttachedPillTop]'s doc comment on +/// [NooBottomBar]) so the icon never shifts vertically as selection +/// changes. Floating drops that reserved space when idle instead: there's +/// no label to leave room for, so the icon centers in the whole button and +/// renders a touch bigger. class _AndroidItem extends StatelessWidget { final NooNavDestination destination; final bool selected; + final bool floating; final VoidCallback onTap; const _AndroidItem({ required this.destination, required this.selected, + required this.floating, required this.onTap, }); @override Widget build(BuildContext context) { final colors = context.nooColors; + final iconColor = selected ? colors.accentText : colors.fg2; + + final Widget content; + if (floating && !selected) { + content = Center( + child: Icon(destination.icon, size: 27, color: iconColor), + ); + } else { + content = Padding( + padding: EdgeInsets.only( + top: floating ? _kFloatingPillTop : _kAttachedPillTop, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + SizedBox( + height: _kAndroidPillHeight, + child: Center( + child: Icon(destination.icon, size: 24, color: iconColor), + ), + ), + 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, + ), + ), + ), + ], + ), + ); + } + return Semantics( button: true, selected: selected, @@ -190,39 +274,7 @@ class _AndroidItem extends StatelessWidget { child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: onTap, - 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, - ), - ), - ), - ], - ), - ), + child: content, ), ); } diff --git a/lib/widgets/noo/nav/noo_nav_style.dart b/lib/widgets/noo/nav/noo_nav_style.dart index fae76e8..ca35d6b 100644 --- a/lib/widgets/noo/nav/noo_nav_style.dart +++ b/lib/widgets/noo/nav/noo_nav_style.dart @@ -17,6 +17,18 @@ enum NooNavStyle { }; } +/// How [NooBottomBar] sits on the screen - user-configurable in Settings +/// (Appearance). [attached] is the original edge-to-edge bar with no +/// side/bottom margin. [floating] insets it 16px from both side edges and +/// clear of the bottom safe area, rounded (28px - the same radius the +/// sheet top/drawer edge use) rather than square, with a 1px `line` border +/// standing in for elevation instead of a shadow (product UI stays flat - +/// DESIGN_SYSTEM.md 1.4 - so this is the one place that border does that +/// job). The host `Scaffold` needs `extendBody: true` while floating, so +/// its body scrolls behind the bar's transparent margin instead of +/// stopping short of it like [attached] does. +enum NooBottomBarStyle { attached, floating } + /// One destination in a [NooBottomBar]. Data-agnostic on purpose - the app /// maps its own tab model (e.g. `AppTab`) onto these. @immutable diff --git a/lib/widgets/settings/settings_appearance.dart b/lib/widgets/settings/settings_appearance.dart index 8ec884a..da02663 100644 --- a/lib/widgets/settings/settings_appearance.dart +++ b/lib/widgets/settings/settings_appearance.dart @@ -7,6 +7,7 @@ import '../../theme/design_tokens.dart'; import '../noo/core/noo_segmented_control.dart'; import '../noo/core/noo_toggle.dart'; import '../noo/lists/noo_settings_row.dart'; +import '../noo/nav/noo_nav_style.dart'; import '../noo/noo_layout.dart'; import '../noo/overlays/noo_dialog.dart'; import '../noo/overlays/noo_sheet.dart'; @@ -47,7 +48,10 @@ class SettingsAppearanceSection extends StatelessWidget { children: [ SettingsSection( title: 'Appearance', - children: [_ThemeRow(settings: settings)], + children: [ + _ThemeRow(settings: settings), + _BottomBarStyleRow(settings: settings), + ], ), const SizedBox(height: NooSpace.xl), SettingsSection( @@ -156,6 +160,64 @@ class _ThemeRow extends StatelessWidget { } } +/// The "Bottom bar" row: same "label, then a full-width segmented control +/// below it" shape as [_ThemeRow] right above it. Attached is the original +/// edge-to-edge bar; floating insets it from both side/bottom edges with +/// rounded corners - see [NooBottomBarStyle]'s own doc comment for the +/// full visual rationale. +class _BottomBarStyleRow extends StatelessWidget { + final SettingsController settings; + + const _BottomBarStyleRow({required this.settings}); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return Material( + color: colors.surface, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: NooSpace.md, + vertical: 12, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(LucideIcons.panelBottom, size: 20, color: colors.fg2), + const SizedBox(width: 14), + Text( + 'Bottom bar', + style: NooText.bodyL.copyWith(color: colors.fg1), + ), + ], + ), + const SizedBox(height: 12), + NooSegmentedControl( + fill: true, + value: settings.bottomBarStyle, + onChanged: settings.setBottomBarStyle, + options: const [ + NooSegmentOption( + value: NooBottomBarStyle.attached, + icon: LucideIcons.panelBottom, + label: 'Attached', + ), + NooSegmentOption( + value: NooBottomBarStyle.floating, + icon: LucideIcons.panelBottomOpen, + label: 'Floating', + ), + ], + ), + ], + ), + ), + ); + } +} + /// The current accent-color choice, shown as a small filled circle in the /// "Accent color" row - a wallpaper icon when following the OS's dynamic /// color instead of a fixed swatch. diff --git a/test/widgets/noo/nav_test.dart b/test/widgets/noo/nav_test.dart index 3b5df93..d2e8bf6 100644 --- a/test/widgets/noo/nav_test.dart +++ b/test/widgets/noo/nav_test.dart @@ -137,5 +137,80 @@ void main() { final labelCenter = tester.getCenter(find.text('Photos')); expect(labelCenter.dy, greaterThan(iconCenter.dy)); }); + + testNooWidgets('floating: inset, rounded, bordered - not edge to edge', ( + tester, + theme, + c, + ) async { + await pumpNoo( + tester, + SizedBox( + width: 360, + height: 96, + child: NooBottomBar( + style: NooNavStyle.android, + barStyle: NooBottomBarStyle.floating, + destinations: _destinations, + selectedIndex: 0, + onSelected: (_) {}, + ), + ), + theme: theme, + ); + final pillFinder = find.descendant( + of: find.byType(NooBottomBar), + matching: find.byWidgetPredicate( + (w) => w is Container && w.decoration != null, + ), + ); + final container = tester.widget(pillFinder); + final decoration = container.decoration as BoxDecoration; + expect(decoration.borderRadius, BorderRadius.circular(28)); + expect(decoration.border, Border.all(color: c.line)); + // Inset from both side edges of the bar's own host box, not flush + // against it - both rects are in the same (global) coordinate space, + // so comparing them directly still holds regardless of where + // `pumpNoo`'s own harness centers that host box on screen. + final hostRect = tester.getRect(find.byType(NooBottomBar)); + final rect = tester.getRect(pillFinder); + expect(rect.left, greaterThan(hostRect.left)); + expect(rect.right, lessThan(hostRect.right)); + }); + + testNooWidgets( + 'floating android: idle icon has no reserved label space and is ' + 'bigger than the active one', + (tester, theme, c) async { + await pumpNoo( + tester, + SizedBox( + width: 360, + height: 96, + child: NooBottomBar( + style: NooNavStyle.android, + barStyle: NooBottomBarStyle.floating, + destinations: _destinations, + selectedIndex: 0, + onSelected: (_) {}, + ), + ), + theme: theme, + ); + // Unlike attached, an idle floating tab has no label at all - not + // just an invisible one - so there's nothing to find here. + expect(find.text('Photos'), findsNothing); + expect( + find.ancestor( + of: find.byIcon(LucideIcons.images), + matching: find.byType(AnimatedOpacity), + ), + findsNothing, + ); + final activeIcon = tester.widget(find.byIcon(LucideIcons.folder)); + final idleIcon = tester.widget(find.byIcon(LucideIcons.images)); + expect(idleIcon.size, greaterThan(activeIcon.size!)); + }, + ); }); }