Add "Search in bottom bar" to Settings
Build APK / build (push) Successful in 5m30s

New Settings > Appearance toggle moves Search into the bottom bar and
out of the top bar, so there's only one search entry point on screen.
Tapping it pushes SearchView same as before - it's never highlighted,
since it isn't a real destination.

- Attached: Search becomes the row's last item, styled like any other
  tab but permanently idle.
- Floating: Search gets its own separate, always fully round satellite
  circle beside the bar instead.

Enabling it costs one regular tab: SettingsController.maxVisibleTabs
is now a dynamic getter (defaultMaxVisibleTabs - 1 while the toggle is
on) instead of the old flat constant, re-enforced immediately if the
user already has all 5 slots filled - the same cleanup path used for
a fresh install or an app update that added a new tab.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-29 22:19:13 -04:00
co-authored by Claude Sonnet 5
parent a70be19f80
commit 58312511c5
11 changed files with 292 additions and 23 deletions
+15 -4
View File
@@ -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,
);
}
}
+12
View File
@@ -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<AppTab> tabs;
final int selectedIndex;
final ValueChanged<int> 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,
);
}
}
+110 -15
View File
@@ -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<NooNavDestination> destinations;
final int selectedIndex;
final ValueChanged<int> 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;
@@ -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),
+2 -1
View File
@@ -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});