Noo design system rebuild #1
@@ -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 |
|
| 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 | 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 - 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 |
|
| Upload | `plus` in the nav bar | Extended FAB |
|
||||||
| Overflow icon | `ellipsis` | `ellipsis-vertical` |
|
| 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. |
|
| 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. |
|
||||||
|
|||||||
@@ -197,6 +197,12 @@ blocks are noted where they matter:
|
|||||||
with a `line` border instead of edge to edge (no opacity/blur knob
|
with a `line` border instead of edge to edge (no opacity/blur knob
|
||||||
either). The host `Scaffold` needs `extendBody: true` while it's active
|
either). The host `Scaffold` needs `extendBody: true` while it's active
|
||||||
(`main.dart` already wires this off `SettingsController.bottomBarStyle`).
|
(`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) —
|
- [`SyncedHeaderScaffold`](../../lib/widgets/synced_header_scaffold.dart) —
|
||||||
the pull-to-sync `CustomScrollView` header with the persistent sync-status
|
the pull-to-sync `CustomScrollView` header with the persistent sync-status
|
||||||
chip and pull-to-refresh gesture/spinner. Every screen (including
|
chip and pull-to-refresh gesture/spinner. Every screen (including
|
||||||
|
|||||||
+11
-1
@@ -473,11 +473,20 @@ class _MainShellViewState extends State<MainShellView> {
|
|||||||
} else {
|
} else {
|
||||||
final navStyle = NooLayout.navStyle(context);
|
final navStyle = NooLayout.navStyle(context);
|
||||||
final bottomBarStyle = settings.bottomBarStyle;
|
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(
|
scaffold = Scaffold(
|
||||||
backgroundColor: colors.bg,
|
backgroundColor: colors.bg,
|
||||||
drawerScrimColor: colors.scrim,
|
drawerScrimColor: colors.scrim,
|
||||||
appBar: pickRequest == null
|
appBar: pickRequest == null
|
||||||
? AppTopBar(style: navStyle, tab: selectedTab)
|
? AppTopBar(
|
||||||
|
style: navStyle,
|
||||||
|
tab: selectedTab,
|
||||||
|
searchInBottomBar: showBottomBarSearch,
|
||||||
|
)
|
||||||
: null,
|
: null,
|
||||||
drawer: pickRequest == null ? const AppDrawer() : null,
|
drawer: pickRequest == null ? const AppDrawer() : null,
|
||||||
// Floating needs the body to draw behind the bar's own transparent
|
// Floating needs the body to draw behind the bar's own transparent
|
||||||
@@ -502,6 +511,7 @@ class _MainShellViewState extends State<MainShellView> {
|
|||||||
barStyle: bottomBarStyle,
|
barStyle: bottomBarStyle,
|
||||||
tabs: pinnedTabs,
|
tabs: pinnedTabs,
|
||||||
selectedIndex: pinnedIndex,
|
selectedIndex: pinnedIndex,
|
||||||
|
onSearchTap: showBottomBarSearch ? () => openSearch(context) : null,
|
||||||
onDestinationSelected: (index) {
|
onDestinationSelected: (index) {
|
||||||
final tappedTab = pinnedTabs[index];
|
final tappedTab = pinnedTabs[index];
|
||||||
if (tappedTab == _currentTab) {
|
if (tappedTab == _currentTab) {
|
||||||
|
|||||||
@@ -16,8 +16,13 @@ enum AppTab {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// At most this many tabs may be visible in the bottom nav bar at once —
|
/// At most this many tabs may be visible in the bottom nav bar at once —
|
||||||
/// the rest are reachable through the "more" dropdown instead.
|
/// the rest are reachable through the "more" dropdown instead. The
|
||||||
const int maxVisibleTabs = 5;
|
/// 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 {
|
extension AppTabInfo on AppTab {
|
||||||
String get label {
|
String get label {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ class SettingsController extends ChangeNotifier {
|
|||||||
static const _prefSwipeLeftAction = 'ui_swipe_left_action';
|
static const _prefSwipeLeftAction = 'ui_swipe_left_action';
|
||||||
static const _prefSwipeRightAction = 'ui_swipe_right_action';
|
static const _prefSwipeRightAction = 'ui_swipe_right_action';
|
||||||
static const _prefBottomBarStyle = 'ui_bottom_bar_style';
|
static const _prefBottomBarStyle = 'ui_bottom_bar_style';
|
||||||
|
static const _prefSearchInBottomBar = 'ui_search_in_bottom_bar';
|
||||||
static const _prefAmoledDark = 'ui_amoled_dark';
|
static const _prefAmoledDark = 'ui_amoled_dark';
|
||||||
static const _prefMediaProgressBarStyle = 'ui_media_progress_bar_style';
|
static const _prefMediaProgressBarStyle = 'ui_media_progress_bar_style';
|
||||||
static const _prefTapTabToScrollTop = 'ui_tap_tab_to_scroll_top';
|
static const _prefTapTabToScrollTop = 'ui_tap_tab_to_scroll_top';
|
||||||
@@ -42,6 +43,7 @@ class SettingsController extends ChangeNotifier {
|
|||||||
Color _seedColor = AppTheme.defaultAccent;
|
Color _seedColor = AppTheme.defaultAccent;
|
||||||
ThemeMode _themeMode = ThemeMode.system;
|
ThemeMode _themeMode = ThemeMode.system;
|
||||||
NooBottomBarStyle _bottomBarStyle = NooBottomBarStyle.attached;
|
NooBottomBarStyle _bottomBarStyle = NooBottomBarStyle.attached;
|
||||||
|
bool _searchInBottomBar = false;
|
||||||
bool _useDynamicColor = true;
|
bool _useDynamicColor = true;
|
||||||
bool _amoledDark = false;
|
bool _amoledDark = false;
|
||||||
MediaProgressBarStyle _mediaProgressBarStyle = MediaProgressBarStyle.wavy;
|
MediaProgressBarStyle _mediaProgressBarStyle = MediaProgressBarStyle.wavy;
|
||||||
@@ -84,11 +86,20 @@ class SettingsController extends ChangeNotifier {
|
|||||||
Color get seedColor => _seedColor;
|
Color get seedColor => _seedColor;
|
||||||
ThemeMode get themeMode => _themeMode;
|
ThemeMode get themeMode => _themeMode;
|
||||||
NooBottomBarStyle get bottomBarStyle => _bottomBarStyle;
|
NooBottomBarStyle get bottomBarStyle => _bottomBarStyle;
|
||||||
|
bool get searchInBottomBar => _searchInBottomBar;
|
||||||
bool get useDynamicColor => _useDynamicColor;
|
bool get useDynamicColor => _useDynamicColor;
|
||||||
bool get amoledDark => _amoledDark;
|
bool get amoledDark => _amoledDark;
|
||||||
MediaProgressBarStyle get mediaProgressBarStyle => _mediaProgressBarStyle;
|
MediaProgressBarStyle get mediaProgressBarStyle => _mediaProgressBarStyle;
|
||||||
bool get tapTabToScrollTop => _tapTabToScrollTop;
|
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
|
/// Every tab in the user's configured order, including hidden ones — used
|
||||||
/// by the reorder/visibility settings UI.
|
/// by the reorder/visibility settings UI.
|
||||||
List<AppTab> get tabOrder => _tabOrder;
|
List<AppTab> get tabOrder => _tabOrder;
|
||||||
@@ -126,6 +137,8 @@ class SettingsController extends ChangeNotifier {
|
|||||||
orElse: () => _bottomBarStyle,
|
orElse: () => _bottomBarStyle,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
_searchInBottomBar =
|
||||||
|
prefs.getBool(_prefSearchInBottomBar) ?? _searchInBottomBar;
|
||||||
_useDynamicColor =
|
_useDynamicColor =
|
||||||
prefs.getBool(_prefUseDynamicColor) ?? _useDynamicColor;
|
prefs.getBool(_prefUseDynamicColor) ?? _useDynamicColor;
|
||||||
_amoledDark = prefs.getBool(_prefAmoledDark) ?? _amoledDark;
|
_amoledDark = prefs.getBool(_prefAmoledDark) ?? _amoledDark;
|
||||||
@@ -274,6 +287,18 @@ class SettingsController extends ChangeNotifier {
|
|||||||
_prefsFuture.then((p) => p.setString(_prefBottomBarStyle, style.name));
|
_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<AppTab> order) {
|
void setTabOrder(List<AppTab> order) {
|
||||||
_tabOrder = order;
|
_tabOrder = order;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|||||||
@@ -19,13 +19,24 @@ import 'shell/shell_common.dart';
|
|||||||
/// [style] is resolved by the caller (`NooLayout.navStyle`) rather than
|
/// [style] is resolved by the caller (`NooLayout.navStyle`) rather than
|
||||||
/// read from context here, since [preferredSize] is a plain getter with no
|
/// read from context here, since [preferredSize] is a plain getter with no
|
||||||
/// BuildContext - the same reason [NooTopBar] itself takes it as a field.
|
/// 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 {
|
class AppTopBar extends StatelessWidget implements PreferredSizeWidget {
|
||||||
final NooNavStyle style;
|
final NooNavStyle style;
|
||||||
final AppTab tab;
|
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 _ios => style == NooNavStyle.ios;
|
||||||
|
bool get _showSearch => !searchInBottomBar;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Size get preferredSize => NooTopBar(
|
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
|
// A throwaway placeholder purely to make preferredSize's "is there a
|
||||||
// search slot" check match what build() actually shows - never
|
// search slot" check match what build() actually shows - never
|
||||||
// inserted into the tree, so its content doesn't matter.
|
// inserted into the tree, so its content doesn't matter.
|
||||||
search: _ios ? const SizedBox.shrink() : null,
|
search: _ios && _showSearch ? const SizedBox.shrink() : null,
|
||||||
).preferredSize;
|
).preferredSize;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -44,7 +55,7 @@ class AppTopBar extends StatelessWidget implements PreferredSizeWidget {
|
|||||||
title: tab.label,
|
title: tab.label,
|
||||||
onMenu: () => Scaffold.of(context).openDrawer(),
|
onMenu: () => Scaffold.of(context).openDrawer(),
|
||||||
actions: [
|
actions: [
|
||||||
if (!_ios)
|
if (!_ios && _showSearch)
|
||||||
NooTopBarButton(
|
NooTopBarButton(
|
||||||
icon: LucideIcons.search,
|
icon: LucideIcons.search,
|
||||||
tooltip: 'Search',
|
tooltip: 'Search',
|
||||||
@@ -58,7 +69,7 @@ class AppTopBar extends StatelessWidget implements PreferredSizeWidget {
|
|||||||
),
|
),
|
||||||
ShellAvatarButton(hitBox: _ios ? 44 : 48),
|
ShellAvatarButton(hitBox: _ios ? 44 : 48),
|
||||||
],
|
],
|
||||||
search: _ios ? const ShellSearchLauncher(ios: true) : null,
|
search: _ios && _showSearch ? const ShellSearchLauncher(ios: true) : null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import '../models/app_tab.dart';
|
import '../models/app_tab.dart';
|
||||||
import 'noo/nav/noo_bottom_bar.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
|
/// 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
|
/// destination highlighted) when the shell is showing a tab opened from the
|
||||||
/// drawer's "More" list that isn't one of the pinned [tabs].
|
/// 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 {
|
class BottomNavBar extends StatelessWidget {
|
||||||
final NooNavStyle style;
|
final NooNavStyle style;
|
||||||
final NooBottomBarStyle barStyle;
|
final NooBottomBarStyle barStyle;
|
||||||
final List<AppTab> tabs;
|
final List<AppTab> tabs;
|
||||||
final int selectedIndex;
|
final int selectedIndex;
|
||||||
final ValueChanged<int> onDestinationSelected;
|
final ValueChanged<int> onDestinationSelected;
|
||||||
|
final VoidCallback? onSearchTap;
|
||||||
|
|
||||||
const BottomNavBar({
|
const BottomNavBar({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -21,6 +28,7 @@ class BottomNavBar extends StatelessWidget {
|
|||||||
required this.tabs,
|
required this.tabs,
|
||||||
required this.selectedIndex,
|
required this.selectedIndex,
|
||||||
required this.onDestinationSelected,
|
required this.onDestinationSelected,
|
||||||
|
this.onSearchTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -34,6 +42,10 @@ class BottomNavBar extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
selectedIndex: selectedIndex,
|
selectedIndex: selectedIndex,
|
||||||
onSelected: onDestinationSelected,
|
onSelected: onDestinationSelected,
|
||||||
|
searchDestination: onSearchTap == null
|
||||||
|
? null
|
||||||
|
: const NooNavDestination(icon: LucideIcons.search, label: 'Search'),
|
||||||
|
onSearchTap: onSearchTap,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,12 +38,21 @@ const double _kAndroidPillHeight = 32;
|
|||||||
/// bottom safe-area inset rather than a fixed 34/20px spacer, so it's
|
/// bottom safe-area inset rather than a fixed 34/20px spacer, so it's
|
||||||
/// right on every device. Meant for `Scaffold.bottomNavigationBar` - the
|
/// right on every device. Meant for `Scaffold.bottomNavigationBar` - the
|
||||||
/// host `Scaffold` needs `extendBody: true` while floating.
|
/// 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 {
|
class NooBottomBar extends StatelessWidget {
|
||||||
final NooNavStyle style;
|
final NooNavStyle style;
|
||||||
final NooBottomBarStyle barStyle;
|
final NooBottomBarStyle barStyle;
|
||||||
final List<NooNavDestination> destinations;
|
final List<NooNavDestination> destinations;
|
||||||
final int selectedIndex;
|
final int selectedIndex;
|
||||||
final ValueChanged<int> onSelected;
|
final ValueChanged<int> onSelected;
|
||||||
|
final NooNavDestination? searchDestination;
|
||||||
|
final VoidCallback? onSearchTap;
|
||||||
|
|
||||||
const NooBottomBar({
|
const NooBottomBar({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -52,6 +61,8 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
required this.destinations,
|
required this.destinations,
|
||||||
required this.selectedIndex,
|
required this.selectedIndex,
|
||||||
required this.onSelected,
|
required this.onSelected,
|
||||||
|
this.searchDestination,
|
||||||
|
this.onSearchTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -59,27 +70,50 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
final colors = context.nooColors;
|
final colors = context.nooColors;
|
||||||
final ios = style == NooNavStyle.ios;
|
final ios = style == NooNavStyle.ios;
|
||||||
final floating = barStyle == NooBottomBarStyle.floating;
|
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
|
final row = ios
|
||||||
? _buildIosRow()
|
? _buildIosRow(trailingSearch: rowSearch)
|
||||||
: _buildAndroidRow(colors, floating: floating);
|
: _buildAndroidRow(
|
||||||
|
colors,
|
||||||
|
floating: floating,
|
||||||
|
trailingSearch: rowSearch,
|
||||||
|
);
|
||||||
final barHeight = ios ? (floating ? 64.0 : 50.0) : (floating ? 72.0 : 80.0);
|
final barHeight = ios ? (floating ? 64.0 : 50.0) : (floating ? 72.0 : 80.0);
|
||||||
|
|
||||||
if (floating) {
|
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(
|
return SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||||
child: Container(
|
child: hasSearch
|
||||||
height: barHeight,
|
? Row(
|
||||||
clipBehavior: Clip.antiAlias,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
decoration: BoxDecoration(
|
children: [
|
||||||
color: colors.surface,
|
Expanded(child: pill),
|
||||||
border: Border.all(color: colors.line),
|
const SizedBox(width: 8),
|
||||||
borderRadius: BorderRadius.circular(28),
|
_SearchSatellite(
|
||||||
),
|
destination: searchDestination!,
|
||||||
child: row,
|
onTap: onSearchTap!,
|
||||||
),
|
size: barHeight,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: pill,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -96,7 +130,7 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildIosRow() {
|
Widget _buildIosRow({NooNavDestination? trailingSearch}) {
|
||||||
return Row(
|
return Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
@@ -108,15 +142,28 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
onTap: () => onSelected(i),
|
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 pillTop = floating ? _kFloatingPillTop : _kAttachedPillTop;
|
||||||
|
final itemCount = destinations.length + (trailingSearch != null ? 1 : 0);
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final itemWidth = constraints.maxWidth / destinations.length;
|
final itemWidth = constraints.maxWidth / itemCount;
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
AnimatedPositioned(
|
AnimatedPositioned(
|
||||||
@@ -147,6 +194,15 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
onTap: () => onSelected(i),
|
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 {
|
class _IosItem extends StatelessWidget {
|
||||||
final NooNavDestination destination;
|
final NooNavDestination destination;
|
||||||
final bool selected;
|
final bool selected;
|
||||||
|
|||||||
@@ -51,6 +51,18 @@ class SettingsAppearanceSection extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
_ThemeRow(settings: settings),
|
_ThemeRow(settings: settings),
|
||||||
_BottomBarStyleRow(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),
|
const SizedBox(height: NooSpace.xl),
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import '../noo/noo_layout.dart';
|
|||||||
import 'settings_section.dart';
|
import 'settings_section.dart';
|
||||||
|
|
||||||
/// Settings section 7: bottom-nav tab order, visibility (pinned = shown, up
|
/// 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 {
|
class SettingsTabsSection extends StatelessWidget {
|
||||||
const SettingsTabsSection({super.key});
|
const SettingsTabsSection({super.key});
|
||||||
|
|
||||||
|
|||||||
@@ -212,5 +212,96 @@ void main() {
|
|||||||
expect(idleIcon.size, greaterThan(activeIcon.size!));
|
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<Container>(
|
||||||
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user