Add a floating bottom bar style, user-configurable in Settings

Settings > Appearance > "Bottom bar" now switches between the
original edge-to-edge bar and a floating one - inset 16px from both
side edges and clear of the bottom safe area, 28px corners (the same
radius the sheet top/drawer edge use), a 1px line border standing in
for elevation instead of a shadow (product UI stays flat). Persisted
via NooBottomBarStyle, defaulting to the existing attached behavior.

On Android, floating's idle tabs drop their reserved label space -
with no label to leave room for, the icon centers in the whole button
and renders a touch bigger (27 vs 24px) instead of sitting high with a
gap held open under it; the active tab's icon/pill/label position is
unchanged. iOS's tab content is unchanged either way, only the
container.

main.dart sets Scaffold.extendBody while floating so the body scrolls
behind the bar's transparent margin instead of stopping short of it,
matching how the design canvas explored it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-29 16:21:13 -04:00
co-authored by Claude Sonnet 5
parent c18fc98dc3
commit a70be19f80
9 changed files with 280 additions and 46 deletions
@@ -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 | | 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 | | 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. |
| 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. |
+6 -1
View File
@@ -191,7 +191,12 @@ blocks are noted where they matter:
opacity/blur knob - flat surfaces per the design system), grid tiles are opacity/blur knob - flat surfaces per the design system), grid tiles are
`NooGridCard`, swipe actions are `NooSwipeAction`, and per-item sync `NooGridCard`, swipe actions are `NooSwipeAction`, and per-item sync
status is `NooFileRow`/`NooFileTableRow`'s built-in `NooStatusIcon` list 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) — - [`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
+6
View File
@@ -472,6 +472,7 @@ class _MainShellViewState extends State<MainShellView> {
); );
} else { } else {
final navStyle = NooLayout.navStyle(context); final navStyle = NooLayout.navStyle(context);
final bottomBarStyle = settings.bottomBarStyle;
scaffold = Scaffold( scaffold = Scaffold(
backgroundColor: colors.bg, backgroundColor: colors.bg,
drawerScrimColor: colors.scrim, drawerScrimColor: colors.scrim,
@@ -479,6 +480,10 @@ class _MainShellViewState extends State<MainShellView> {
? AppTopBar(style: navStyle, tab: selectedTab) ? AppTopBar(style: navStyle, tab: selectedTab)
: 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
// 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` // Android-only extended Upload FAB - iOS uses the top bar's `plus`
// instead (see AppTopBar). Stays mounted across every tab (picking // instead (see AppTopBar). Stays mounted across every tab (picking
// aside) and collapses to an icon-only circle off Files/Photos, // aside) and collapses to an icon-only circle off Files/Photos,
@@ -494,6 +499,7 @@ class _MainShellViewState extends State<MainShellView> {
body: tabStack, body: tabStack,
bottomNavigationBar: BottomNavBar( bottomNavigationBar: BottomNavBar(
style: navStyle, style: navStyle,
barStyle: bottomBarStyle,
tabs: pinnedTabs, tabs: pinnedTabs,
selectedIndex: pinnedIndex, selectedIndex: pinnedIndex,
onDestinationSelected: (index) { onDestinationSelected: (index) {
+18
View File
@@ -3,6 +3,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../models/app_tab.dart'; import '../models/app_tab.dart';
import '../models/selection_action.dart'; import '../models/selection_action.dart';
import '../theme/app_theme.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 /// What swiping a Files list-view item left/right does, user-configurable
/// in Settings. /// in Settings.
@@ -30,6 +31,7 @@ class SettingsController extends ChangeNotifier {
static const _prefDefaultTab = 'ui_default_tab'; static const _prefDefaultTab = 'ui_default_tab';
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 _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';
@@ -39,6 +41,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;
bool _useDynamicColor = true; bool _useDynamicColor = true;
bool _amoledDark = false; bool _amoledDark = false;
MediaProgressBarStyle _mediaProgressBarStyle = MediaProgressBarStyle.wavy; MediaProgressBarStyle _mediaProgressBarStyle = MediaProgressBarStyle.wavy;
@@ -80,6 +83,7 @@ class SettingsController extends ChangeNotifier {
Color get seedColor => _seedColor; Color get seedColor => _seedColor;
ThemeMode get themeMode => _themeMode; ThemeMode get themeMode => _themeMode;
NooBottomBarStyle get bottomBarStyle => _bottomBarStyle;
bool get useDynamicColor => _useDynamicColor; bool get useDynamicColor => _useDynamicColor;
bool get amoledDark => _amoledDark; bool get amoledDark => _amoledDark;
MediaProgressBarStyle get mediaProgressBarStyle => _mediaProgressBarStyle; MediaProgressBarStyle get mediaProgressBarStyle => _mediaProgressBarStyle;
@@ -115,6 +119,13 @@ class SettingsController extends ChangeNotifier {
orElse: () => ThemeMode.system, orElse: () => ThemeMode.system,
); );
} }
final bottomBarStyleName = prefs.getString(_prefBottomBarStyle);
if (bottomBarStyleName != null) {
_bottomBarStyle = NooBottomBarStyle.values.firstWhere(
(s) => s.name == bottomBarStyleName,
orElse: () => _bottomBarStyle,
);
}
_useDynamicColor = _useDynamicColor =
prefs.getBool(_prefUseDynamicColor) ?? _useDynamicColor; prefs.getBool(_prefUseDynamicColor) ?? _useDynamicColor;
_amoledDark = prefs.getBool(_prefAmoledDark) ?? _amoledDark; _amoledDark = prefs.getBool(_prefAmoledDark) ?? _amoledDark;
@@ -256,6 +267,13 @@ class SettingsController extends ChangeNotifier {
_prefsFuture.then((p) => p.setString(_prefThemeMode, mode.name)); _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<AppTab> order) { void setTabOrder(List<AppTab> order) {
_tabOrder = order; _tabOrder = order;
notifyListeners(); notifyListeners();
+3
View File
@@ -9,6 +9,7 @@ import 'noo/nav/noo_bottom_bar.dart';
/// drawer's "More" list that isn't one of the pinned [tabs]. /// drawer's "More" list that isn't one of the pinned [tabs].
class BottomNavBar extends StatelessWidget { class BottomNavBar extends StatelessWidget {
final NooNavStyle style; final NooNavStyle style;
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;
@@ -16,6 +17,7 @@ class BottomNavBar extends StatelessWidget {
const BottomNavBar({ const BottomNavBar({
super.key, super.key,
required this.style, required this.style,
this.barStyle = NooBottomBarStyle.attached,
required this.tabs, required this.tabs,
required this.selectedIndex, required this.selectedIndex,
required this.onDestinationSelected, required this.onDestinationSelected,
@@ -25,6 +27,7 @@ class BottomNavBar extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return NooBottomBar( return NooBottomBar(
style: style, style: style,
barStyle: barStyle,
destinations: [ destinations: [
for (final tab in tabs) for (final tab in tabs)
NooNavDestination(icon: tab.icon, label: tab.label), NooNavDestination(icon: tab.icon, label: tab.label),
+78 -26
View File
@@ -5,8 +5,11 @@ import 'noo_nav_style.dart';
export 'noo_nav_style.dart'; export 'noo_nav_style.dart';
/// Android indicator geometry, shared between the sliding pill and each /// Android indicator geometry, shared between the sliding pill and each
/// item's own icon box so they line up exactly. /// item's own icon box so they line up exactly. Floating's own top offset
const double _kAndroidPillTop = 14; /// 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 _kAndroidPillWidth = 56;
const double _kAndroidPillHeight = 32; const double _kAndroidPillHeight = 32;
@@ -23,11 +26,21 @@ const double _kAndroidPillHeight = 32;
/// 56x32 accent-soft pill that slides between tabs as selection moves, /// 56x32 accent-soft pill that slides between tabs as selection moves,
/// rather than popping in/out on the destination item itself. /// 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 /// 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 /// 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 { class NooBottomBar extends StatelessWidget {
final NooNavStyle style; final NooNavStyle style;
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;
@@ -35,6 +48,7 @@ class NooBottomBar extends StatelessWidget {
const NooBottomBar({ const NooBottomBar({
super.key, super.key,
required this.style, required this.style,
this.barStyle = NooBottomBarStyle.attached,
required this.destinations, required this.destinations,
required this.selectedIndex, required this.selectedIndex,
required this.onSelected, required this.onSelected,
@@ -44,8 +58,31 @@ class NooBottomBar extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = context.nooColors; final colors = context.nooColors;
final ios = style == NooNavStyle.ios; 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( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -54,7 +91,7 @@ class NooBottomBar extends StatelessWidget {
), ),
child: SafeArea( child: SafeArea(
top: false, 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( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final itemWidth = constraints.maxWidth / destinations.length; final itemWidth = constraints.maxWidth / destinations.length;
@@ -84,7 +122,7 @@ class NooBottomBar extends StatelessWidget {
AnimatedPositioned( AnimatedPositioned(
duration: NooMotion.base, duration: NooMotion.base,
curve: NooMotion.ease, curve: NooMotion.ease,
top: _kAndroidPillTop, top: pillTop,
left: left:
itemWidth * selectedIndex + itemWidth * selectedIndex +
(itemWidth - _kAndroidPillWidth) / 2, (itemWidth - _kAndroidPillWidth) / 2,
@@ -105,6 +143,7 @@ class NooBottomBar extends StatelessWidget {
child: _AndroidItem( child: _AndroidItem(
destination: destinations[i], destination: destinations[i],
selected: i == selectedIndex, selected: i == selectedIndex,
floating: floating,
onTap: () => onSelected(i), onTap: () => onSelected(i),
), ),
), ),
@@ -165,44 +204,47 @@ class _IosItem extends StatelessWidget {
} }
/// Icon (over the shared sliding pill, drawn separately by [NooBottomBar]) /// Icon (over the shared sliding pill, drawn separately by [NooBottomBar])
/// with its label always below it - the label's space is reserved /// with its label below it. Attached always reserves the label's space
/// whether or not it's showing (only its opacity changes), so the icon /// (just invisible when idle, per [_kAttachedPillTop]'s doc comment on
/// never shifts vertically as selection changes, and stays aligned with /// [NooBottomBar]) so the icon never shifts vertically as selection
/// the pill's fixed [_kAndroidPillTop]/[_kAndroidPillHeight]. /// 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 { class _AndroidItem extends StatelessWidget {
final NooNavDestination destination; final NooNavDestination destination;
final bool selected; final bool selected;
final bool floating;
final VoidCallback onTap; final VoidCallback onTap;
const _AndroidItem({ const _AndroidItem({
required this.destination, required this.destination,
required this.selected, required this.selected,
required this.floating,
required this.onTap, required this.onTap,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = context.nooColors; final colors = context.nooColors;
return Semantics( final iconColor = selected ? colors.accentText : colors.fg2;
button: true,
selected: selected, final Widget content;
label: destination.label, if (floating && !selected) {
child: GestureDetector( content = Center(
behavior: HitTestBehavior.opaque, child: Icon(destination.icon, size: 27, color: iconColor),
onTap: onTap, );
child: Padding( } else {
padding: const EdgeInsets.only(top: _kAndroidPillTop), content = Padding(
padding: EdgeInsets.only(
top: floating ? _kFloatingPillTop : _kAttachedPillTop,
),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start,
children: [ children: [
SizedBox( SizedBox(
height: _kAndroidPillHeight, height: _kAndroidPillHeight,
child: Center( child: Center(
child: Icon( child: Icon(destination.icon, size: 24, color: iconColor),
destination.icon,
size: 24,
color: selected ? colors.accentText : colors.fg2,
),
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
@@ -222,7 +264,17 @@ class _AndroidItem extends StatelessWidget {
), ),
], ],
), ),
), );
}
return Semantics(
button: true,
selected: selected,
label: destination.label,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: content,
), ),
); );
} }
+12
View File
@@ -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 /// One destination in a [NooBottomBar]. Data-agnostic on purpose - the app
/// maps its own tab model (e.g. `AppTab`) onto these. /// maps its own tab model (e.g. `AppTab`) onto these.
@immutable @immutable
+63 -1
View File
@@ -7,6 +7,7 @@ import '../../theme/design_tokens.dart';
import '../noo/core/noo_segmented_control.dart'; import '../noo/core/noo_segmented_control.dart';
import '../noo/core/noo_toggle.dart'; import '../noo/core/noo_toggle.dart';
import '../noo/lists/noo_settings_row.dart'; import '../noo/lists/noo_settings_row.dart';
import '../noo/nav/noo_nav_style.dart';
import '../noo/noo_layout.dart'; import '../noo/noo_layout.dart';
import '../noo/overlays/noo_dialog.dart'; import '../noo/overlays/noo_dialog.dart';
import '../noo/overlays/noo_sheet.dart'; import '../noo/overlays/noo_sheet.dart';
@@ -47,7 +48,10 @@ class SettingsAppearanceSection extends StatelessWidget {
children: [ children: [
SettingsSection( SettingsSection(
title: 'Appearance', title: 'Appearance',
children: [_ThemeRow(settings: settings)], children: [
_ThemeRow(settings: settings),
_BottomBarStyleRow(settings: settings),
],
), ),
const SizedBox(height: NooSpace.xl), const SizedBox(height: NooSpace.xl),
SettingsSection( 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<NooBottomBarStyle>(
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 /// 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 /// "Accent color" row - a wallpaper icon when following the OS's dynamic
/// color instead of a fixed swatch. /// color instead of a fixed swatch.
+75
View File
@@ -137,5 +137,80 @@ void main() {
final labelCenter = tester.getCenter(find.text('Photos')); final labelCenter = tester.getCenter(find.text('Photos'));
expect(labelCenter.dy, greaterThan(iconCenter.dy)); 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<Container>(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<Icon>(find.byIcon(LucideIcons.folder));
final idleIcon = tester.widget<Icon>(find.byIcon(LucideIcons.images));
expect(idleIcon.size, greaterThan(activeIcon.size!));
},
);
}); });
} }