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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
d6448984d9
commit
944b87b054
@@ -233,7 +233,16 @@ blocks are noted where they matter:
|
|||||||
that constant's doc comment) instead of edge to edge (no opacity/blur knob
|
that constant's doc comment) instead of edge to edge (no opacity/blur knob
|
||||||
either). `floating` ignores `NooNavStyle` and always uses the Android row
|
either). `floating` ignores `NooNavStyle` and always uses the Android row
|
||||||
(icon-only idle tabs, sliding pill, 64px), so iOS matches Android there;
|
(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`),
|
(`main.dart` already wires this off `SettingsController.bottomBarStyle`),
|
||||||
which also means every tab's own scrollable list has to reserve enough
|
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
|
bottom padding to clear the bar - nothing does that automatically once the
|
||||||
|
|||||||
+8
-3
@@ -36,7 +36,7 @@ import 'widgets/create_menu.dart';
|
|||||||
import 'widgets/noo/core/noo_avatar.dart';
|
import 'widgets/noo/core/noo_avatar.dart';
|
||||||
import 'widgets/noo/core/noo_button.dart';
|
import 'widgets/noo/core/noo_button.dart';
|
||||||
import 'widgets/noo/core/noo_fab.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_sidebar.dart';
|
||||||
import 'widgets/noo/nav/noo_toolbar.dart';
|
import 'widgets/noo/nav/noo_toolbar.dart';
|
||||||
import 'widgets/noo/noo_layout.dart';
|
import 'widgets/noo/noo_layout.dart';
|
||||||
@@ -526,8 +526,12 @@ class _MainShellViewState extends State<MainShellView> {
|
|||||||
settings.navMenuStyle == NooNavMenuStyle.drawer,
|
settings.navMenuStyle == NooNavMenuStyle.drawer,
|
||||||
// Floating needs the body to draw behind the bar's own transparent
|
// Floating needs the body to draw behind the bar's own transparent
|
||||||
// margin (see NooBottomBarStyle's doc comment) instead of stopping
|
// margin (see NooBottomBarStyle's doc comment) instead of stopping
|
||||||
// short of it like attached does.
|
// short of it like attached does; a frosted bar needs it too, or
|
||||||
extendBody: bottomBarStyle == NooBottomBarStyle.floating,
|
// 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`
|
// 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,
|
||||||
@@ -549,6 +553,7 @@ class _MainShellViewState extends State<MainShellView> {
|
|||||||
bottomNavigationBar: BottomNavBar(
|
bottomNavigationBar: BottomNavBar(
|
||||||
style: navStyle,
|
style: navStyle,
|
||||||
barStyle: bottomBarStyle,
|
barStyle: bottomBarStyle,
|
||||||
|
frosted: settings.bottomBarFrosted,
|
||||||
tabs: pinnedTabs,
|
tabs: pinnedTabs,
|
||||||
selectedIndex: pinnedIndex,
|
selectedIndex: pinnedIndex,
|
||||||
onSearchTap: showBottomBarSearch ? () => openSearch(context) : null,
|
onSearchTap: showBottomBarSearch ? () => openSearch(context) : null,
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ class SettingsController extends ChangeNotifier {
|
|||||||
static const _prefSwipeRightAction = 'ui_swipe_right_action';
|
static const _prefSwipeRightAction = 'ui_swipe_right_action';
|
||||||
static const _prefFabStyle = 'ui_fab_style';
|
static const _prefFabStyle = 'ui_fab_style';
|
||||||
static const _prefBottomBarStyle = 'ui_bottom_bar_style';
|
static const _prefBottomBarStyle = 'ui_bottom_bar_style';
|
||||||
|
static const _prefBottomBarFrosted = 'ui_bottom_bar_frosted';
|
||||||
static const _prefNavMenuStyle = 'ui_nav_menu_style';
|
static const _prefNavMenuStyle = 'ui_nav_menu_style';
|
||||||
static const _prefSearchInBottomBar = 'ui_search_in_bottom_bar';
|
static const _prefSearchInBottomBar = 'ui_search_in_bottom_bar';
|
||||||
static const _prefAmoledDark = 'ui_amoled_dark';
|
static const _prefAmoledDark = 'ui_amoled_dark';
|
||||||
@@ -51,6 +52,7 @@ class SettingsController extends ChangeNotifier {
|
|||||||
ThemeMode _themeMode = ThemeMode.system;
|
ThemeMode _themeMode = ThemeMode.system;
|
||||||
NooBottomBarStyle _bottomBarStyle = NooBottomBarStyle.attached;
|
NooBottomBarStyle _bottomBarStyle = NooBottomBarStyle.attached;
|
||||||
NooNavMenuStyle _navMenuStyle = NooNavMenuStyle.drawer;
|
NooNavMenuStyle _navMenuStyle = NooNavMenuStyle.drawer;
|
||||||
|
bool _bottomBarFrosted = false;
|
||||||
bool _searchInBottomBar = false;
|
bool _searchInBottomBar = false;
|
||||||
bool _useDynamicColor = true;
|
bool _useDynamicColor = true;
|
||||||
bool _amoledDark = false;
|
bool _amoledDark = false;
|
||||||
@@ -95,6 +97,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;
|
NooBottomBarStyle get bottomBarStyle => _bottomBarStyle;
|
||||||
|
bool get bottomBarFrosted => _bottomBarFrosted;
|
||||||
NooNavMenuStyle get navMenuStyle => _navMenuStyle;
|
NooNavMenuStyle get navMenuStyle => _navMenuStyle;
|
||||||
bool get searchInBottomBar => _searchInBottomBar;
|
bool get searchInBottomBar => _searchInBottomBar;
|
||||||
bool get useDynamicColor => _useDynamicColor;
|
bool get useDynamicColor => _useDynamicColor;
|
||||||
@@ -155,6 +158,8 @@ class SettingsController extends ChangeNotifier {
|
|||||||
orElse: () => _navMenuStyle,
|
orElse: () => _navMenuStyle,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
_bottomBarFrosted =
|
||||||
|
prefs.getBool(_prefBottomBarFrosted) ?? _bottomBarFrosted;
|
||||||
_searchInBottomBar =
|
_searchInBottomBar =
|
||||||
prefs.getBool(_prefSearchInBottomBar) ?? _searchInBottomBar;
|
prefs.getBool(_prefSearchInBottomBar) ?? _searchInBottomBar;
|
||||||
_useDynamicColor =
|
_useDynamicColor =
|
||||||
@@ -313,6 +318,13 @@ class SettingsController extends ChangeNotifier {
|
|||||||
_prefsFuture.then((p) => p.setString(_prefBottomBarStyle, style.name));
|
_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) {
|
void setNavMenuStyle(NooNavMenuStyle style) {
|
||||||
if (_navMenuStyle == style) return;
|
if (_navMenuStyle == style) return;
|
||||||
_navMenuStyle = style;
|
_navMenuStyle = style;
|
||||||
|
|||||||
@@ -837,10 +837,13 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
),
|
),
|
||||||
// List/grid/table above only pad NooSpace.lg at the bottom - plenty
|
// List/grid/table above only pad NooSpace.lg at the bottom - plenty
|
||||||
// once Scaffold shrinks the body above an attached bar, but floating
|
// 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
|
// draws the body behind the bar instead (as does a frosted bar), so it
|
||||||
// footprint added on top or the last row ends up under it.
|
// needs the bar's own footprint added on top or the last row ends up
|
||||||
if (context.watch<SettingsController>().bottomBarStyle ==
|
// under it.
|
||||||
NooBottomBarStyle.floating)
|
if (NooBottomBar.drawsBehindBody(
|
||||||
|
context.watch<SettingsController>().bottomBarStyle,
|
||||||
|
context.watch<SettingsController>().bottomBarFrosted,
|
||||||
|
))
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SizedBox(height: bottomBarClearance(context)),
|
child: SizedBox(height: bottomBarClearance(context)),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import 'noo/nav/noo_bottom_bar.dart';
|
|||||||
class BottomNavBar extends StatelessWidget {
|
class BottomNavBar extends StatelessWidget {
|
||||||
final NooNavStyle style;
|
final NooNavStyle style;
|
||||||
final NooBottomBarStyle barStyle;
|
final NooBottomBarStyle barStyle;
|
||||||
|
final bool frosted;
|
||||||
final List<AppTab> tabs;
|
final List<AppTab> tabs;
|
||||||
final int selectedIndex;
|
final int selectedIndex;
|
||||||
final ValueChanged<int> onDestinationSelected;
|
final ValueChanged<int> onDestinationSelected;
|
||||||
@@ -25,6 +26,7 @@ class BottomNavBar extends StatelessWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
required this.style,
|
required this.style,
|
||||||
this.barStyle = NooBottomBarStyle.attached,
|
this.barStyle = NooBottomBarStyle.attached,
|
||||||
|
this.frosted = false,
|
||||||
required this.tabs,
|
required this.tabs,
|
||||||
required this.selectedIndex,
|
required this.selectedIndex,
|
||||||
required this.onDestinationSelected,
|
required this.onDestinationSelected,
|
||||||
@@ -36,6 +38,7 @@ class BottomNavBar extends StatelessWidget {
|
|||||||
return NooBottomBar(
|
return NooBottomBar(
|
||||||
style: style,
|
style: style,
|
||||||
barStyle: barStyle,
|
barStyle: barStyle,
|
||||||
|
frosted: frosted,
|
||||||
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),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:ui' show ImageFilter;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../theme/design_tokens.dart';
|
import '../../../theme/design_tokens.dart';
|
||||||
import 'noo_nav_style.dart';
|
import 'noo_nav_style.dart';
|
||||||
@@ -13,6 +14,11 @@ const double _kFloatingPillTop = 8;
|
|||||||
const double _kAndroidPillWidth = 56;
|
const double _kAndroidPillWidth = 56;
|
||||||
const double _kAndroidPillHeight = 32;
|
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
|
/// 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
|
/// `Mobile Screen.dc.html`). Shows the 5 pinned tabs - which ones, and in
|
||||||
/// what order, is the caller's business; this only draws [destinations].
|
/// 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
|
/// body draws behind it - see `tab_state_slivers.dart`'s
|
||||||
/// `bottomBarClearance`.
|
/// `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'
|
/// [searchDestination]/[onSearchTap] (set together, from Settings'
|
||||||
/// "Search in bottom bar" - see `DESIGN_SYSTEM.md`'s floating bottom bar
|
/// "Search in bottom bar" - see `DESIGN_SYSTEM.md`'s floating bottom bar
|
||||||
/// entry) add a Search entry that's never highlighted (tapping it pushes
|
/// entry) add a Search entry that's never highlighted (tapping it pushes
|
||||||
@@ -57,6 +70,7 @@ const double _kAndroidPillHeight = 32;
|
|||||||
class NooBottomBar extends StatelessWidget {
|
class NooBottomBar extends StatelessWidget {
|
||||||
final NooNavStyle style;
|
final NooNavStyle style;
|
||||||
final NooBottomBarStyle barStyle;
|
final NooBottomBarStyle barStyle;
|
||||||
|
final bool frosted;
|
||||||
final List<NooNavDestination> destinations;
|
final List<NooNavDestination> destinations;
|
||||||
final int selectedIndex;
|
final int selectedIndex;
|
||||||
final ValueChanged<int> onSelected;
|
final ValueChanged<int> onSelected;
|
||||||
@@ -67,6 +81,7 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
required this.style,
|
required this.style,
|
||||||
this.barStyle = NooBottomBarStyle.attached,
|
this.barStyle = NooBottomBarStyle.attached,
|
||||||
|
this.frosted = false,
|
||||||
required this.destinations,
|
required this.destinations,
|
||||||
required this.selectedIndex,
|
required this.selectedIndex,
|
||||||
required this.onSelected,
|
required this.onSelected,
|
||||||
@@ -84,6 +99,12 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
return barStyle == NooBottomBarStyle.floating ? 64 : 80;
|
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
|
/// Gap between the floating bar's bottom edge and the safe area below it
|
||||||
/// (itself inside the [SafeArea] that consumes the actual device inset).
|
/// (itself inside the [SafeArea] that consumes the actual device inset).
|
||||||
static const double floatingBottomMargin = 12;
|
static const double floatingBottomMargin = 12;
|
||||||
@@ -111,17 +132,19 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
final barHeight = NooBottomBar.rowHeight(style, barStyle);
|
final barHeight = NooBottomBar.rowHeight(style, barStyle);
|
||||||
|
|
||||||
if (floating) {
|
if (floating) {
|
||||||
final pill = Container(
|
final pillRadius = BorderRadius.circular(28);
|
||||||
|
final pillBox = Container(
|
||||||
height: barHeight,
|
height: barHeight,
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colors.surface,
|
color: _fill(colors),
|
||||||
border: Border.all(color: colors.line),
|
border: Border.all(color: colors.line),
|
||||||
borderRadius: BorderRadius.circular(28),
|
borderRadius: pillRadius,
|
||||||
boxShadow: const [nooDialogShadow],
|
boxShadow: frosted ? null : const [nooDialogShadow],
|
||||||
),
|
),
|
||||||
child: row,
|
child: row,
|
||||||
);
|
);
|
||||||
|
final pill = frosted ? _blurred(pillBox, pillRadius) : pillBox;
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -152,6 +175,7 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
destination: searchDestination!,
|
destination: searchDestination!,
|
||||||
onTap: onSearchTap!,
|
onTap: onSearchTap!,
|
||||||
size: barHeight,
|
size: barHeight,
|
||||||
|
frosted: frosted,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -161,9 +185,9 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Container(
|
final bar = Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colors.surface,
|
color: _fill(colors),
|
||||||
border: ios ? Border(top: BorderSide(color: colors.line)) : null,
|
border: ios ? Border(top: BorderSide(color: colors.line)) : null,
|
||||||
),
|
),
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
@@ -171,8 +195,13 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
child: SizedBox(height: barHeight, child: row),
|
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}) {
|
Widget _buildIosRow({NooNavDestination? trailingSearch}) {
|
||||||
return Row(
|
return Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
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
|
/// The floating bar's own Search entry point, next to the pill rather than
|
||||||
/// inside it - always fully round (see [NooBottomBar]'s doc comment),
|
/// inside it - always fully round (see [NooBottomBar]'s doc comment),
|
||||||
/// same fill/border as the pill so the two still read as one family.
|
/// 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 NooNavDestination destination;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
final double size;
|
final double size;
|
||||||
|
final bool frosted;
|
||||||
|
|
||||||
const _SearchSatellite({
|
const _SearchSatellite({
|
||||||
required this.destination,
|
required this.destination,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
required this.size,
|
required this.size,
|
||||||
|
required this.frosted,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -278,21 +319,28 @@ class _SearchSatellite extends StatelessWidget {
|
|||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Container(
|
child: _satelliteBody(colors),
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
class _IosItem extends StatelessWidget {
|
||||||
|
|||||||
@@ -53,6 +53,17 @@ class SettingsAppearanceSection extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
_ThemeRow(settings: settings),
|
_ThemeRow(settings: settings),
|
||||||
_BottomBarStyleRow(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),
|
_NavMenuStyleRow(settings: settings),
|
||||||
NooSettingsRow(
|
NooSettingsRow(
|
||||||
icon: LucideIcons.search,
|
icon: LucideIcons.search,
|
||||||
|
|||||||
@@ -97,19 +97,26 @@ Widget tabEmptySliver(
|
|||||||
/// attached] bars aren't drawn behind (no `Scaffold.extendBody`), so
|
/// attached] bars aren't drawn behind (no `Scaffold.extendBody`), so
|
||||||
/// Scaffold already shrinks the body above them - this is then pure
|
/// Scaffold already shrinks the body above them - this is then pure
|
||||||
/// breathing room, not overlap prevention. [NooBottomBarStyle.floating]
|
/// 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
|
/// them automatically: the clearance has to cover the bar's own footprint
|
||||||
/// (see [NooBottomBar.rowHeight]/[NooBottomBar.floatingBottomMargin]) plus
|
/// (see [NooBottomBar.rowHeight]/[NooBottomBar.floatingBottomMargin]) plus
|
||||||
/// the safe-area inset below it, or the last row ends up hidden under it.
|
/// the safe-area inset below it, or the last row ends up hidden under it.
|
||||||
double bottomBarClearance(BuildContext context) {
|
double bottomBarClearance(BuildContext context) {
|
||||||
final settings = context.watch<SettingsController>();
|
final settings = context.watch<SettingsController>();
|
||||||
if (settings.bottomBarStyle == NooBottomBarStyle.attached) return 100;
|
final barStyle = settings.bottomBarStyle;
|
||||||
|
if (!NooBottomBar.drawsBehindBody(barStyle, settings.bottomBarFrosted)) {
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
final barHeight = NooBottomBar.rowHeight(
|
final barHeight = NooBottomBar.rowHeight(
|
||||||
NooLayout.navStyle(context),
|
NooLayout.navStyle(context),
|
||||||
NooBottomBarStyle.floating,
|
barStyle,
|
||||||
);
|
);
|
||||||
|
final margin = barStyle == NooBottomBarStyle.floating
|
||||||
|
? NooBottomBar.floatingBottomMargin
|
||||||
|
: 0;
|
||||||
final safeBottom = MediaQuery.paddingOf(context).bottom;
|
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 -
|
/// Bottom padding sliver so the last row/card clears the bottom nav/FAB -
|
||||||
|
|||||||
@@ -25,6 +25,69 @@ void main() {
|
|||||||
setUpNooTests();
|
setUpNooTests();
|
||||||
|
|
||||||
group('NooBottomBar', () {
|
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', (
|
testNooWidgets('android: label sits below the icon, not beside it', (
|
||||||
tester,
|
tester,
|
||||||
theme,
|
theme,
|
||||||
|
|||||||
Reference in New Issue
Block a user