From 944b87b054cdc9fe9b72c5a339aa39a970822b7e Mon Sep 17 00:00:00 2001 From: Ayushya Amitabh Date: Mon, 5 Oct 2026 22:16:27 -0400 Subject: [PATCH] Bottom bar: add frosted glass option (Settings > Appearance) Translucent fill over a backdrop blur, both platforms and both bar styles. Frosted bars draw over an extended body, so the shell's extendBody and the tab clearance now key off NooBottomBar.drawsBehindBody. Co-Authored-By: Claude Sonnet 5.5 --- .claude/context/styling.md | 11 ++- lib/main.dart | 11 ++- lib/providers/settings_controller.dart | 12 +++ lib/views/files_view.dart | 11 ++- lib/widgets/bottom_nav_bar.dart | 3 + lib/widgets/noo/nav/noo_bottom_bar.dart | 84 +++++++++++++++---- lib/widgets/settings/settings_appearance.dart | 11 +++ lib/widgets/tabs/tab_state_slivers.dart | 15 +++- test/widgets/noo/nav_test.dart | 63 ++++++++++++++ 9 files changed, 191 insertions(+), 30 deletions(-) diff --git a/.claude/context/styling.md b/.claude/context/styling.md index 4ec3b36..4bf437c 100644 --- a/.claude/context/styling.md +++ b/.claude/context/styling.md @@ -233,7 +233,16 @@ blocks are noted where they matter: that constant's doc comment) instead of edge to edge (no opacity/blur knob either). `floating` ignores `NooNavStyle` and always uses the Android row (icon-only idle tabs, sliding pill, 64px), so iOS matches Android there; - only the attached bar still has a distinct iOS row. The host `Scaffold` needs `extendBody: true` while it's active + only the attached bar still has a distinct iOS row. A separate, orthogonal + `SettingsController.bottomBarFrosted` toggle (Settings → Appearance → + "Frosted glass bottom bar", off by default, both platforms and both bar + styles) swaps the bar's solid `surface` for a 0.72-alpha one over a + 20-sigma `BackdropFilter` (same sigma as `FrostedGlassContainer`) and drops + `nooDialogShadow` (it would show through the glass). A frosted bar only + reads as glass with content behind it, so the shell uses + `NooBottomBar.drawsBehindBody(barStyle, frosted)` for `extendBody`, and + `bottomBarClearance` (and Files' own trailing sliver) reserve the bar's + footprint for attached+frosted too, not just floating. The host `Scaffold` needs `extendBody: true` while it's active (`main.dart` already wires this off `SettingsController.bottomBarStyle`), which also means every tab's own scrollable list has to reserve enough bottom padding to clear the bar - nothing does that automatically once the diff --git a/lib/main.dart b/lib/main.dart index 69a5951..1a4006f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -36,7 +36,7 @@ import 'widgets/create_menu.dart'; import 'widgets/noo/core/noo_avatar.dart'; import 'widgets/noo/core/noo_button.dart'; import 'widgets/noo/core/noo_fab.dart'; -import 'widgets/noo/nav/noo_nav_style.dart'; +import 'widgets/noo/nav/noo_bottom_bar.dart'; import 'widgets/noo/nav/noo_sidebar.dart'; import 'widgets/noo/nav/noo_toolbar.dart'; import 'widgets/noo/noo_layout.dart'; @@ -526,8 +526,12 @@ class _MainShellViewState extends State { settings.navMenuStyle == NooNavMenuStyle.drawer, // 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, + // short of it like attached does; a frosted bar needs it too, or + // there'd be nothing behind it to blur. + extendBody: NooBottomBar.drawsBehindBody( + bottomBarStyle, + settings.bottomBarFrosted, + ), // Android-only extended Upload FAB - iOS uses the top bar's `plus` // instead (see AppTopBar). Stays mounted across every tab (picking // aside) and collapses to an icon-only circle off Files/Photos, @@ -549,6 +553,7 @@ class _MainShellViewState extends State { bottomNavigationBar: BottomNavBar( style: navStyle, barStyle: bottomBarStyle, + frosted: settings.bottomBarFrosted, tabs: pinnedTabs, selectedIndex: pinnedIndex, onSearchTap: showBottomBarSearch ? () => openSearch(context) : null, diff --git a/lib/providers/settings_controller.dart b/lib/providers/settings_controller.dart index 3a1340b..b01098a 100644 --- a/lib/providers/settings_controller.dart +++ b/lib/providers/settings_controller.dart @@ -38,6 +38,7 @@ class SettingsController extends ChangeNotifier { static const _prefSwipeRightAction = 'ui_swipe_right_action'; static const _prefFabStyle = 'ui_fab_style'; static const _prefBottomBarStyle = 'ui_bottom_bar_style'; + static const _prefBottomBarFrosted = 'ui_bottom_bar_frosted'; static const _prefNavMenuStyle = 'ui_nav_menu_style'; static const _prefSearchInBottomBar = 'ui_search_in_bottom_bar'; static const _prefAmoledDark = 'ui_amoled_dark'; @@ -51,6 +52,7 @@ class SettingsController extends ChangeNotifier { ThemeMode _themeMode = ThemeMode.system; NooBottomBarStyle _bottomBarStyle = NooBottomBarStyle.attached; NooNavMenuStyle _navMenuStyle = NooNavMenuStyle.drawer; + bool _bottomBarFrosted = false; bool _searchInBottomBar = false; bool _useDynamicColor = true; bool _amoledDark = false; @@ -95,6 +97,7 @@ class SettingsController extends ChangeNotifier { Color get seedColor => _seedColor; ThemeMode get themeMode => _themeMode; NooBottomBarStyle get bottomBarStyle => _bottomBarStyle; + bool get bottomBarFrosted => _bottomBarFrosted; NooNavMenuStyle get navMenuStyle => _navMenuStyle; bool get searchInBottomBar => _searchInBottomBar; bool get useDynamicColor => _useDynamicColor; @@ -155,6 +158,8 @@ class SettingsController extends ChangeNotifier { orElse: () => _navMenuStyle, ); } + _bottomBarFrosted = + prefs.getBool(_prefBottomBarFrosted) ?? _bottomBarFrosted; _searchInBottomBar = prefs.getBool(_prefSearchInBottomBar) ?? _searchInBottomBar; _useDynamicColor = @@ -313,6 +318,13 @@ class SettingsController extends ChangeNotifier { _prefsFuture.then((p) => p.setString(_prefBottomBarStyle, style.name)); } + void setBottomBarFrosted(bool value) { + if (_bottomBarFrosted == value) return; + _bottomBarFrosted = value; + notifyListeners(); + _prefsFuture.then((p) => p.setBool(_prefBottomBarFrosted, value)); + } + void setNavMenuStyle(NooNavMenuStyle style) { if (_navMenuStyle == style) return; _navMenuStyle = style; diff --git a/lib/views/files_view.dart b/lib/views/files_view.dart index 32ab264..c15e109 100644 --- a/lib/views/files_view.dart +++ b/lib/views/files_view.dart @@ -837,10 +837,13 @@ class _FilesViewState extends State { ), // List/grid/table above only pad NooSpace.lg at the bottom - plenty // once Scaffold shrinks the body above an attached bar, but floating - // draws the body behind the bar instead, so it needs the bar's own - // footprint added on top or the last row ends up under it. - if (context.watch().bottomBarStyle == - NooBottomBarStyle.floating) + // draws the body behind the bar instead (as does a frosted bar), so it + // needs the bar's own footprint added on top or the last row ends up + // under it. + if (NooBottomBar.drawsBehindBody( + context.watch().bottomBarStyle, + context.watch().bottomBarFrosted, + )) SliverToBoxAdapter( child: SizedBox(height: bottomBarClearance(context)), ), diff --git a/lib/widgets/bottom_nav_bar.dart b/lib/widgets/bottom_nav_bar.dart index f116a38..c2dce07 100644 --- a/lib/widgets/bottom_nav_bar.dart +++ b/lib/widgets/bottom_nav_bar.dart @@ -16,6 +16,7 @@ import 'noo/nav/noo_bottom_bar.dart'; class BottomNavBar extends StatelessWidget { final NooNavStyle style; final NooBottomBarStyle barStyle; + final bool frosted; final List tabs; final int selectedIndex; final ValueChanged onDestinationSelected; @@ -25,6 +26,7 @@ class BottomNavBar extends StatelessWidget { super.key, required this.style, this.barStyle = NooBottomBarStyle.attached, + this.frosted = false, required this.tabs, required this.selectedIndex, required this.onDestinationSelected, @@ -36,6 +38,7 @@ class BottomNavBar extends StatelessWidget { return NooBottomBar( style: style, barStyle: barStyle, + frosted: frosted, destinations: [ for (final tab in tabs) NooNavDestination(icon: tab.icon, label: tab.label), diff --git a/lib/widgets/noo/nav/noo_bottom_bar.dart b/lib/widgets/noo/nav/noo_bottom_bar.dart index d4784aa..95a3ce0 100644 --- a/lib/widgets/noo/nav/noo_bottom_bar.dart +++ b/lib/widgets/noo/nav/noo_bottom_bar.dart @@ -1,3 +1,4 @@ +import 'dart:ui' show ImageFilter; import 'package:flutter/material.dart'; import '../../../theme/design_tokens.dart'; import 'noo_nav_style.dart'; @@ -13,6 +14,11 @@ const double _kFloatingPillTop = 8; const double _kAndroidPillWidth = 56; const double _kAndroidPillHeight = 32; +/// Frosted glass: the surface fill's opacity and the backdrop blur sigma - +/// the same sigma [FrostedGlassContainer] uses for the media viewer chrome. +const double _kFrostedAlpha = 0.72; +const double _kFrostedSigma = 20; + /// The mobile bottom bar (DESIGN_SYSTEM.md 3, "Mobile"; `iNav`/`aNav` in /// `Mobile Screen.dc.html`). Shows the 5 pinned tabs - which ones, and in /// what order, is the caller's business; this only draws [destinations]. @@ -48,6 +54,13 @@ const double _kAndroidPillHeight = 32; /// body draws behind it - see `tab_state_slivers.dart`'s /// `bottomBarClearance`. /// +/// [frosted] (Settings, Appearance) swaps the solid `surface` fill for a +/// translucent one over a backdrop blur, on both bar styles and both nav +/// styles, and drops [nooDialogShadow] (a shadow would show through the +/// glass). It only reads as glass if content draws behind the bar, so the +/// host `Scaffold` needs `extendBody: true` and each scrollable body needs +/// the same trailing clearance floating does - see [drawsBehindBody]. +/// /// [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 @@ -57,6 +70,7 @@ const double _kAndroidPillHeight = 32; class NooBottomBar extends StatelessWidget { final NooNavStyle style; final NooBottomBarStyle barStyle; + final bool frosted; final List destinations; final int selectedIndex; final ValueChanged onSelected; @@ -67,6 +81,7 @@ class NooBottomBar extends StatelessWidget { super.key, required this.style, this.barStyle = NooBottomBarStyle.attached, + this.frosted = false, required this.destinations, required this.selectedIndex, required this.onSelected, @@ -84,6 +99,12 @@ class NooBottomBar extends StatelessWidget { return barStyle == NooBottomBarStyle.floating ? 64 : 80; } + /// Whether the host `Scaffold` must draw its body behind the bar + /// (`extendBody`) - true for floating (its transparent margin) and for any + /// frosted bar (there's nothing to blur otherwise). + static bool drawsBehindBody(NooBottomBarStyle barStyle, bool frosted) => + frosted || barStyle == NooBottomBarStyle.floating; + /// Gap between the floating bar's bottom edge and the safe area below it /// (itself inside the [SafeArea] that consumes the actual device inset). static const double floatingBottomMargin = 12; @@ -111,17 +132,19 @@ class NooBottomBar extends StatelessWidget { final barHeight = NooBottomBar.rowHeight(style, barStyle); if (floating) { - final pill = Container( + final pillRadius = BorderRadius.circular(28); + final pillBox = Container( height: barHeight, clipBehavior: Clip.antiAlias, decoration: BoxDecoration( - color: colors.surface, + color: _fill(colors), border: Border.all(color: colors.line), - borderRadius: BorderRadius.circular(28), - boxShadow: const [nooDialogShadow], + borderRadius: pillRadius, + boxShadow: frosted ? null : const [nooDialogShadow], ), child: row, ); + final pill = frosted ? _blurred(pillBox, pillRadius) : pillBox; return SafeArea( top: false, child: Padding( @@ -152,6 +175,7 @@ class NooBottomBar extends StatelessWidget { destination: searchDestination!, onTap: onSearchTap!, size: barHeight, + frosted: frosted, ), ], ), @@ -161,9 +185,9 @@ class NooBottomBar extends StatelessWidget { ); } - return Container( + final bar = Container( decoration: BoxDecoration( - color: colors.surface, + color: _fill(colors), border: ios ? Border(top: BorderSide(color: colors.line)) : null, ), child: SafeArea( @@ -171,8 +195,13 @@ class NooBottomBar extends StatelessWidget { child: SizedBox(height: barHeight, child: row), ), ); + return frosted ? _blurred(bar, BorderRadius.zero) : bar; } + Color _fill(NooColors colors) => frosted + ? colors.surface.withValues(alpha: _kFrostedAlpha) + : colors.surface; + Widget _buildIosRow({NooNavDestination? trailingSearch}) { return Row( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -255,6 +284,16 @@ class NooBottomBar extends StatelessWidget { } } +/// Clips [child] to [radius] and blurs whatever is drawn behind it - the +/// frosted bar's backdrop. [child] supplies the translucent fill on top. +Widget _blurred(Widget child, BorderRadius radius) => ClipRRect( + borderRadius: radius, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: _kFrostedSigma, sigmaY: _kFrostedSigma), + child: child, + ), +); + /// 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. @@ -262,11 +301,13 @@ class _SearchSatellite extends StatelessWidget { final NooNavDestination destination; final VoidCallback onTap; final double size; + final bool frosted; const _SearchSatellite({ required this.destination, required this.onTap, required this.size, + required this.frosted, }); @override @@ -278,21 +319,28 @@ class _SearchSatellite extends StatelessWidget { 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, - boxShadow: const [nooDialogShadow], - ), - child: Icon(destination.icon, size: 24, color: colors.fg1), - ), + child: _satelliteBody(colors), ), ); } + + Widget _satelliteBody(NooColors colors) { + final disc = Container( + width: size, + height: size, + alignment: Alignment.center, + decoration: BoxDecoration( + color: frosted + ? colors.surface.withValues(alpha: _kFrostedAlpha) + : colors.surface, + border: Border.all(color: colors.line), + shape: BoxShape.circle, + boxShadow: frosted ? null : const [nooDialogShadow], + ), + child: Icon(destination.icon, size: 24, color: colors.fg1), + ); + return frosted ? _blurred(disc, BorderRadius.circular(size / 2)) : disc; + } } class _IosItem extends StatelessWidget { diff --git a/lib/widgets/settings/settings_appearance.dart b/lib/widgets/settings/settings_appearance.dart index 23ce453..23d73c5 100644 --- a/lib/widgets/settings/settings_appearance.dart +++ b/lib/widgets/settings/settings_appearance.dart @@ -53,6 +53,17 @@ class SettingsAppearanceSection extends StatelessWidget { children: [ _ThemeRow(settings: settings), _BottomBarStyleRow(settings: settings), + NooSettingsRow( + icon: LucideIcons.glassWater, + label: const Text('Frosted glass bottom bar'), + subtitle: const Text( + 'A translucent, blurred bottom bar - content scrolls behind it', + ), + trailing: NooToggle( + checked: settings.bottomBarFrosted, + onChanged: settings.setBottomBarFrosted, + ), + ), _NavMenuStyleRow(settings: settings), NooSettingsRow( icon: LucideIcons.search, diff --git a/lib/widgets/tabs/tab_state_slivers.dart b/lib/widgets/tabs/tab_state_slivers.dart index d1a4e89..957f8b0 100644 --- a/lib/widgets/tabs/tab_state_slivers.dart +++ b/lib/widgets/tabs/tab_state_slivers.dart @@ -97,19 +97,26 @@ Widget tabEmptySliver( /// attached] bars aren't drawn behind (no `Scaffold.extendBody`), so /// Scaffold already shrinks the body above them - this is then pure /// breathing room, not overlap prevention. [NooBottomBarStyle.floating] -/// bars draw over an extended body instead, so nothing reserves space for +/// and frosted bars (see [NooBottomBar.drawsBehindBody]) draw over an +/// extended body instead, so nothing reserves space for /// them automatically: the clearance has to cover the bar's own footprint /// (see [NooBottomBar.rowHeight]/[NooBottomBar.floatingBottomMargin]) plus /// the safe-area inset below it, or the last row ends up hidden under it. double bottomBarClearance(BuildContext context) { final settings = context.watch(); - if (settings.bottomBarStyle == NooBottomBarStyle.attached) return 100; + final barStyle = settings.bottomBarStyle; + if (!NooBottomBar.drawsBehindBody(barStyle, settings.bottomBarFrosted)) { + return 100; + } final barHeight = NooBottomBar.rowHeight( NooLayout.navStyle(context), - NooBottomBarStyle.floating, + barStyle, ); + final margin = barStyle == NooBottomBarStyle.floating + ? NooBottomBar.floatingBottomMargin + : 0; final safeBottom = MediaQuery.paddingOf(context).bottom; - return barHeight + NooBottomBar.floatingBottomMargin + safeBottom + 24; + return barHeight + margin + safeBottom + 24; } /// Bottom padding sliver so the last row/card clears the bottom nav/FAB - diff --git a/test/widgets/noo/nav_test.dart b/test/widgets/noo/nav_test.dart index 9a0ccf5..39d3fb6 100644 --- a/test/widgets/noo/nav_test.dart +++ b/test/widgets/noo/nav_test.dart @@ -25,6 +25,69 @@ void main() { setUpNooTests(); group('NooBottomBar', () { + for (final style in NooNavStyle.values) { + for (final barStyle in NooBottomBarStyle.values) { + testNooWidgets( + '$style/$barStyle: frosted draws a backdrop blur, solid does not', + (tester, theme, c) async { + for (final frosted in [false, true]) { + await pumpNoo( + tester, + SizedBox( + width: 360, + height: 120, + child: NooBottomBar( + style: style, + barStyle: barStyle, + frosted: frosted, + destinations: _destinations, + selectedIndex: 0, + onSelected: (_) {}, + searchDestination: const NooNavDestination( + icon: LucideIcons.search, + label: 'Search', + ), + onSearchTap: () {}, + ), + ), + theme: theme, + ); + expect( + find.byType(BackdropFilter), + frosted ? findsWidgets : findsNothing, + ); + } + }, + ); + } + } + + test('drawsBehindBody: floating or frosted', () { + expect( + NooBottomBar.drawsBehindBody(NooBottomBarStyle.attached, false), + isFalse, + ); + expect( + NooBottomBar.drawsBehindBody(NooBottomBarStyle.attached, true), + isTrue, + ); + expect( + NooBottomBar.drawsBehindBody(NooBottomBarStyle.floating, false), + isTrue, + ); + }); + + test('floating row height is the Android one on both nav styles', () { + expect( + NooBottomBar.rowHeight(NooNavStyle.ios, NooBottomBarStyle.floating), + 64, + ); + expect( + NooBottomBar.rowHeight(NooNavStyle.ios, NooBottomBarStyle.attached), + 50, + ); + }); + testNooWidgets('android: label sits below the icon, not beside it', ( tester, theme,