diff --git a/.claude/context/design-system/DESIGN_SYSTEM.md b/.claude/context/design-system/DESIGN_SYSTEM.md index 2ddead3..3ddbc59 100644 --- a/.claude/context/design-system/DESIGN_SYSTEM.md +++ b/.claude/context/design-system/DESIGN_SYSTEM.md @@ -274,6 +274,7 @@ Mobile always has **5 pinned tabs in the bottom bar**. The rest go in the drawer | 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. | +| Bottom bar - "Search in bottom bar" (Settings → Appearance, either bar style) | Adds a Search entry that's never highlighted (tapping it pushes Search, same as the top bar's own search action, which this replaces so there's only one entry point on screen) - the row's last item when attached (same styling as a real tab, just permanently idle), or its own separate, always fully round satellite circle beside the bar when floating. Costs one regular tab: `SettingsController.maxVisibleTabs` drops from 5 to 4 while this is on. | | 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 b541e87..0220b14 100644 --- a/.claude/context/styling.md +++ b/.claude/context/styling.md @@ -197,6 +197,12 @@ blocks are noted where they matter: 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`). + Settings' separate "Search in bottom bar" toggle + (`SettingsController.searchInBottomBar`) adds a never-highlighted Search + entry to either bar style (`NooBottomBar`'s `searchDestination`/ + `onSearchTap`) and lowers `SettingsController.maxVisibleTabs` by one - + use that getter, not `defaultMaxVisibleTabs` from `models/app_tab.dart`, + anywhere that needs the *current* cap on regular tabs. - [`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 256ba94..0b44578 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -473,11 +473,20 @@ class _MainShellViewState extends State { } else { final navStyle = NooLayout.navStyle(context); final bottomBarStyle = settings.bottomBarStyle; + // Picking/offline already override the tab set itself (see + // pinnedTabs above) - Search doesn't belong in either: there's + // nothing to search for a file-picking flow, and Search needs the + // network Offline mode doesn't have. + final showBottomBarSearch = !overrideActive && settings.searchInBottomBar; scaffold = Scaffold( backgroundColor: colors.bg, drawerScrimColor: colors.scrim, appBar: pickRequest == null - ? AppTopBar(style: navStyle, tab: selectedTab) + ? AppTopBar( + style: navStyle, + tab: selectedTab, + searchInBottomBar: showBottomBarSearch, + ) : null, drawer: pickRequest == null ? const AppDrawer() : null, // Floating needs the body to draw behind the bar's own transparent @@ -502,6 +511,7 @@ class _MainShellViewState extends State { barStyle: bottomBarStyle, tabs: pinnedTabs, selectedIndex: pinnedIndex, + onSearchTap: showBottomBarSearch ? () => openSearch(context) : null, onDestinationSelected: (index) { final tappedTab = pinnedTabs[index]; if (tappedTab == _currentTab) { diff --git a/lib/models/app_tab.dart b/lib/models/app_tab.dart index 8fc5181..b34ca41 100644 --- a/lib/models/app_tab.dart +++ b/lib/models/app_tab.dart @@ -16,8 +16,13 @@ enum AppTab { } /// At most this many tabs may be visible in the bottom nav bar at once — -/// the rest are reachable through the "more" dropdown instead. -const int maxVisibleTabs = 5; +/// the rest are reachable through the "more" dropdown instead. The +/// effective cap a screen should actually use is +/// [SettingsController.maxVisibleTabs], not this directly - it's one +/// lower than this while `searchInBottomBar` is on, since Search then +/// takes the freed-up slot itself rather than competing with the user's +/// own reorderable tabs for one of these. +const int defaultMaxVisibleTabs = 5; extension AppTabInfo on AppTab { String get label { diff --git a/lib/providers/settings_controller.dart b/lib/providers/settings_controller.dart index b7bfec4..0220cc8 100644 --- a/lib/providers/settings_controller.dart +++ b/lib/providers/settings_controller.dart @@ -32,6 +32,7 @@ class SettingsController extends ChangeNotifier { static const _prefSwipeLeftAction = 'ui_swipe_left_action'; static const _prefSwipeRightAction = 'ui_swipe_right_action'; static const _prefBottomBarStyle = 'ui_bottom_bar_style'; + static const _prefSearchInBottomBar = 'ui_search_in_bottom_bar'; static const _prefAmoledDark = 'ui_amoled_dark'; static const _prefMediaProgressBarStyle = 'ui_media_progress_bar_style'; static const _prefTapTabToScrollTop = 'ui_tap_tab_to_scroll_top'; @@ -42,6 +43,7 @@ class SettingsController extends ChangeNotifier { Color _seedColor = AppTheme.defaultAccent; ThemeMode _themeMode = ThemeMode.system; NooBottomBarStyle _bottomBarStyle = NooBottomBarStyle.attached; + bool _searchInBottomBar = false; bool _useDynamicColor = true; bool _amoledDark = false; MediaProgressBarStyle _mediaProgressBarStyle = MediaProgressBarStyle.wavy; @@ -84,11 +86,20 @@ class SettingsController extends ChangeNotifier { Color get seedColor => _seedColor; ThemeMode get themeMode => _themeMode; NooBottomBarStyle get bottomBarStyle => _bottomBarStyle; + bool get searchInBottomBar => _searchInBottomBar; bool get useDynamicColor => _useDynamicColor; bool get amoledDark => _amoledDark; MediaProgressBarStyle get mediaProgressBarStyle => _mediaProgressBarStyle; bool get tapTabToScrollTop => _tapTabToScrollTop; + /// The cap a screen should actually enforce for the *regular*, user- + /// reorderable tabs - one below [defaultMaxVisibleTabs] while + /// [searchInBottomBar] is on, since Search then takes that freed-up slot + /// itself (the row's last tab when attached, its own satellite circle + /// when floating - see `NooBottomBar`) rather than counting against it. + int get maxVisibleTabs => + defaultMaxVisibleTabs - (_searchInBottomBar ? 1 : 0); + /// Every tab in the user's configured order, including hidden ones — used /// by the reorder/visibility settings UI. List get tabOrder => _tabOrder; @@ -126,6 +137,8 @@ class SettingsController extends ChangeNotifier { orElse: () => _bottomBarStyle, ); } + _searchInBottomBar = + prefs.getBool(_prefSearchInBottomBar) ?? _searchInBottomBar; _useDynamicColor = prefs.getBool(_prefUseDynamicColor) ?? _useDynamicColor; _amoledDark = prefs.getBool(_prefAmoledDark) ?? _amoledDark; @@ -274,6 +287,18 @@ class SettingsController extends ChangeNotifier { _prefsFuture.then((p) => p.setString(_prefBottomBarStyle, style.name)); } + /// Turning this on lowers [maxVisibleTabs] by one, so re-enforces the cap + /// immediately in case the user already has a full 5 regular tabs pinned + /// - same cleanup [_enforceMaxVisibleTabs] already does for a fresh + /// install/an app update adding a new tab. + void setSearchInBottomBar(bool value) { + if (_searchInBottomBar == value) return; + _searchInBottomBar = value; + if (value) _enforceMaxVisibleTabs(); + notifyListeners(); + _prefsFuture.then((p) => p.setBool(_prefSearchInBottomBar, value)); + } + void setTabOrder(List order) { _tabOrder = order; notifyListeners(); diff --git a/lib/widgets/app_top_bar.dart b/lib/widgets/app_top_bar.dart index 70d329c..0c46fa7 100644 --- a/lib/widgets/app_top_bar.dart +++ b/lib/widgets/app_top_bar.dart @@ -19,13 +19,24 @@ import 'shell/shell_common.dart'; /// [style] is resolved by the caller (`NooLayout.navStyle`) rather than /// read from context here, since [preferredSize] is a plain getter with no /// BuildContext - the same reason [NooTopBar] itself takes it as a field. +/// [searchInBottomBar] is [SettingsController.searchInBottomBar], passed in +/// for the same reason: it hides this bar's own search field/icon so +/// there's only ever one search entry point on screen once the bottom bar +/// has taken it over. class AppTopBar extends StatelessWidget implements PreferredSizeWidget { final NooNavStyle style; final AppTab tab; + final bool searchInBottomBar; - const AppTopBar({super.key, required this.style, required this.tab}); + const AppTopBar({ + super.key, + required this.style, + required this.tab, + this.searchInBottomBar = false, + }); bool get _ios => style == NooNavStyle.ios; + bool get _showSearch => !searchInBottomBar; @override Size get preferredSize => NooTopBar( @@ -34,7 +45,7 @@ class AppTopBar extends StatelessWidget implements PreferredSizeWidget { // A throwaway placeholder purely to make preferredSize's "is there a // search slot" check match what build() actually shows - never // inserted into the tree, so its content doesn't matter. - search: _ios ? const SizedBox.shrink() : null, + search: _ios && _showSearch ? const SizedBox.shrink() : null, ).preferredSize; @override @@ -44,7 +55,7 @@ class AppTopBar extends StatelessWidget implements PreferredSizeWidget { title: tab.label, onMenu: () => Scaffold.of(context).openDrawer(), actions: [ - if (!_ios) + if (!_ios && _showSearch) NooTopBarButton( icon: LucideIcons.search, tooltip: 'Search', @@ -58,7 +69,7 @@ class AppTopBar extends StatelessWidget implements PreferredSizeWidget { ), ShellAvatarButton(hitBox: _ios ? 44 : 48), ], - search: _ios ? const ShellSearchLauncher(ios: true) : null, + search: _ios && _showSearch ? const ShellSearchLauncher(ios: true) : null, ); } } diff --git a/lib/widgets/bottom_nav_bar.dart b/lib/widgets/bottom_nav_bar.dart index 1da95de..f116a38 100644 --- a/lib/widgets/bottom_nav_bar.dart +++ b/lib/widgets/bottom_nav_bar.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../models/app_tab.dart'; import 'noo/nav/noo_bottom_bar.dart'; @@ -7,12 +8,18 @@ import 'noo/nav/noo_bottom_bar.dart'; /// doesn't need to import `noo/nav` itself. [selectedIndex] may be -1 (no /// destination highlighted) when the shell is showing a tab opened from the /// drawer's "More" list that isn't one of the pinned [tabs]. +/// +/// [onSearchTap] (from Settings' "Search in bottom bar") adds the Search +/// entry [NooBottomBar] itself knows how to place (row's last item when +/// attached, its own satellite when floating) - null leaves the bar +/// exactly as before. class BottomNavBar extends StatelessWidget { final NooNavStyle style; final NooBottomBarStyle barStyle; final List tabs; final int selectedIndex; final ValueChanged onDestinationSelected; + final VoidCallback? onSearchTap; const BottomNavBar({ super.key, @@ -21,6 +28,7 @@ class BottomNavBar extends StatelessWidget { required this.tabs, required this.selectedIndex, required this.onDestinationSelected, + this.onSearchTap, }); @override @@ -34,6 +42,10 @@ class BottomNavBar extends StatelessWidget { ], selectedIndex: selectedIndex, onSelected: onDestinationSelected, + searchDestination: onSearchTap == null + ? null + : const NooNavDestination(icon: LucideIcons.search, label: 'Search'), + onSearchTap: onSearchTap, ); } } diff --git a/lib/widgets/noo/nav/noo_bottom_bar.dart b/lib/widgets/noo/nav/noo_bottom_bar.dart index 807365f..ef19ba7 100644 --- a/lib/widgets/noo/nav/noo_bottom_bar.dart +++ b/lib/widgets/noo/nav/noo_bottom_bar.dart @@ -38,12 +38,21 @@ const double _kAndroidPillHeight = 32; /// bottom safe-area inset rather than a fixed 34/20px spacer, so it's /// right on every device. Meant for `Scaffold.bottomNavigationBar` - the /// host `Scaffold` needs `extendBody: true` while floating. +/// +/// [searchDestination]/[onSearchTap] (set together, from Settings' +/// "Search in bottom bar" - see `DESIGN_SYSTEM.md`'s floating bottom bar +/// entry) add a Search entry that's never highlighted (tapping it pushes +/// `SearchView`, it doesn't select anything) - the row's last item when +/// attached, or its own satellite circle beside the bar when floating, +/// always fully round regardless of the bar's own corner radius. class NooBottomBar extends StatelessWidget { final NooNavStyle style; final NooBottomBarStyle barStyle; final List destinations; final int selectedIndex; final ValueChanged onSelected; + final NooNavDestination? searchDestination; + final VoidCallback? onSearchTap; const NooBottomBar({ super.key, @@ -52,6 +61,8 @@ class NooBottomBar extends StatelessWidget { required this.destinations, required this.selectedIndex, required this.onSelected, + this.searchDestination, + this.onSearchTap, }); @override @@ -59,27 +70,50 @@ class NooBottomBar extends StatelessWidget { final colors = context.nooColors; final ios = style == NooNavStyle.ios; final floating = barStyle == NooBottomBarStyle.floating; + final hasSearch = searchDestination != null && onSearchTap != null; + // Attached folds Search into the row itself (last item); floating + // gives it a separate satellite circle instead (built below), so the + // row builders only ever see it as a trailing item in the former case. + final rowSearch = hasSearch && !floating ? searchDestination : null; final row = ios - ? _buildIosRow() - : _buildAndroidRow(colors, floating: floating); + ? _buildIosRow(trailingSearch: rowSearch) + : _buildAndroidRow( + colors, + floating: floating, + trailingSearch: rowSearch, + ); final barHeight = ios ? (floating ? 64.0 : 50.0) : (floating ? 72.0 : 80.0); if (floating) { + final pill = Container( + height: barHeight, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: colors.surface, + border: Border.all(color: colors.line), + borderRadius: BorderRadius.circular(28), + ), + child: row, + ); 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, - ), + child: hasSearch + ? Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(child: pill), + const SizedBox(width: 8), + _SearchSatellite( + destination: searchDestination!, + onTap: onSearchTap!, + size: barHeight, + ), + ], + ) + : pill, ), ); } @@ -96,7 +130,7 @@ class NooBottomBar extends StatelessWidget { ); } - Widget _buildIosRow() { + Widget _buildIosRow({NooNavDestination? trailingSearch}) { return Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -108,15 +142,28 @@ class NooBottomBar extends StatelessWidget { onTap: () => onSelected(i), ), ), + if (trailingSearch != null) + Expanded( + child: _IosItem( + destination: trailingSearch, + selected: false, + onTap: onSearchTap!, + ), + ), ], ); } - Widget _buildAndroidRow(NooColors colors, {required bool floating}) { + Widget _buildAndroidRow( + NooColors colors, { + required bool floating, + NooNavDestination? trailingSearch, + }) { final pillTop = floating ? _kFloatingPillTop : _kAttachedPillTop; + final itemCount = destinations.length + (trailingSearch != null ? 1 : 0); return LayoutBuilder( builder: (context, constraints) { - final itemWidth = constraints.maxWidth / destinations.length; + final itemWidth = constraints.maxWidth / itemCount; return Stack( children: [ AnimatedPositioned( @@ -147,6 +194,15 @@ class NooBottomBar extends StatelessWidget { onTap: () => onSelected(i), ), ), + if (trailingSearch != null) + Expanded( + child: _AndroidItem( + destination: trailingSearch, + selected: false, + floating: floating, + onTap: onSearchTap!, + ), + ), ], ), ], @@ -156,6 +212,45 @@ class NooBottomBar extends StatelessWidget { } } +/// The floating bar's own Search entry point, next to the pill rather than +/// inside it - always fully round (see [NooBottomBar]'s doc comment), +/// same fill/border as the pill so the two still read as one family. +class _SearchSatellite extends StatelessWidget { + final NooNavDestination destination; + final VoidCallback onTap; + final double size; + + const _SearchSatellite({ + required this.destination, + required this.onTap, + required this.size, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return Semantics( + button: true, + label: destination.label, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Container( + width: size, + height: size, + alignment: Alignment.center, + decoration: BoxDecoration( + color: colors.surface, + border: Border.all(color: colors.line), + shape: BoxShape.circle, + ), + child: Icon(destination.icon, size: 24, color: colors.fg1), + ), + ), + ); + } +} + class _IosItem extends StatelessWidget { final NooNavDestination destination; final bool selected; diff --git a/lib/widgets/settings/settings_appearance.dart b/lib/widgets/settings/settings_appearance.dart index da02663..a2c8a79 100644 --- a/lib/widgets/settings/settings_appearance.dart +++ b/lib/widgets/settings/settings_appearance.dart @@ -51,6 +51,18 @@ class SettingsAppearanceSection extends StatelessWidget { children: [ _ThemeRow(settings: settings), _BottomBarStyleRow(settings: settings), + NooSettingsRow( + icon: LucideIcons.search, + label: const Text('Search in bottom bar'), + subtitle: const Text( + 'Adds Search to the bottom bar and removes it from the top ' + 'bar - leaves room for one fewer regular tab', + ), + trailing: NooToggle( + checked: settings.searchInBottomBar, + onChanged: settings.setSearchInBottomBar, + ), + ), ], ), const SizedBox(height: NooSpace.xl), diff --git a/lib/widgets/settings/settings_tabs.dart b/lib/widgets/settings/settings_tabs.dart index 94ef147..494a76c 100644 --- a/lib/widgets/settings/settings_tabs.dart +++ b/lib/widgets/settings/settings_tabs.dart @@ -11,7 +11,8 @@ import '../noo/noo_layout.dart'; import 'settings_section.dart'; /// Settings section 7: bottom-nav tab order, visibility (pinned = shown, up -/// to [maxVisibleTabs]) and default tab. +/// to [SettingsController.maxVisibleTabs] - one fewer while Appearance's +/// "Search in bottom bar" is on) and default tab. class SettingsTabsSection extends StatelessWidget { const SettingsTabsSection({super.key}); diff --git a/test/widgets/noo/nav_test.dart b/test/widgets/noo/nav_test.dart index d2e8bf6..ac3d53f 100644 --- a/test/widgets/noo/nav_test.dart +++ b/test/widgets/noo/nav_test.dart @@ -212,5 +212,96 @@ void main() { expect(idleIcon.size, greaterThan(activeIcon.size!)); }, ); + + testNooWidgets( + 'attached: search is the row\'s last item and never highlights', + (tester, theme, c) async { + var searchTaps = 0; + 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), + searchDestination: const NooNavDestination( + icon: LucideIcons.search, + label: 'Search', + ), + onSearchTap: () => searchTaps++, + ), + ), + ), + theme: theme, + ); + expect(find.byIcon(LucideIcons.search), findsOneWidget); + final iconCenters = [ + tester.getCenter(find.byIcon(LucideIcons.folder)), + tester.getCenter(find.byIcon(LucideIcons.images)), + tester.getCenter(find.byIcon(LucideIcons.star)), + tester.getCenter(find.byIcon(LucideIcons.search)), + ]; + // Evenly spaced, search landing after every real destination. + for (var i = 1; i < iconCenters.length; i++) { + expect(iconCenters[i].dx, greaterThan(iconCenters[i - 1].dx)); + } + + await tester.tap(find.byIcon(LucideIcons.search)); + await tester.pump(); + expect(searchTaps, 1); + // Tapping search never selects it or moves the indicator. + expect(selected, 0); + expect(_indicator(tester, c), findsOneWidget); + final indicatorLeft = tester.getTopLeft(_indicator(tester, c)).dx; + expect(indicatorLeft, lessThan(iconCenters[3].dx)); + }, + ); + + testNooWidgets('floating: search is a separate round satellite', ( + tester, + theme, + c, + ) async { + var searchTaps = 0; + await pumpNoo( + tester, + SizedBox( + width: 360, + height: 96, + child: NooBottomBar( + style: NooNavStyle.ios, + barStyle: NooBottomBarStyle.floating, + destinations: _destinations, + selectedIndex: 0, + onSelected: (_) {}, + searchDestination: const NooNavDestination( + icon: LucideIcons.search, + label: 'Search', + ), + onSearchTap: () => searchTaps++, + ), + ), + theme: theme, + ); + // Not part of the main pill - its own separately-tappable icon. + expect(find.text('Search'), findsNothing); + final satellite = tester.widget( + find.ancestor( + of: find.byIcon(LucideIcons.search), + matching: find.byType(Container), + ), + ); + final decoration = satellite.decoration as BoxDecoration; + expect(decoration.shape, BoxShape.circle); + + await tester.tap(find.byIcon(LucideIcons.search)); + await tester.pump(); + expect(searchTaps, 1); + }); }); }