Frosted bottom bar: adjustable blur and opacity with Less/Default/More presets

Settings > Appearance shows a preset control plus Blur and Opacity sliders
(new NooSlider) while frosted glass is on; values persist and feed
NooBottomBar.frostedBlur/frostedOpacity. Default matches the old fixed look.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Ayushya Amitabh
2026-10-06 19:46:54 -04:00
co-authored by Claude Sonnet 5.5
parent 0c063c1fb4
commit 0cda4a1241
10 changed files with 431 additions and 16 deletions
+2
View File
@@ -564,6 +564,8 @@ class _MainShellViewState extends State<MainShellView> {
style: navStyle,
barStyle: bottomBarStyle,
frosted: settings.bottomBarFrosted,
frostedBlur: settings.bottomBarFrostedBlur,
frostedOpacity: settings.bottomBarFrostedOpacity,
tabs: pinnedTabs,
selectedIndex: pinnedIndex,
onSearchTap: showBottomBarSearch ? () => openSearch(context) : null,
+66
View File
@@ -21,6 +21,30 @@ enum FabStyle { auto, mini, expanded }
/// animated wave with a tick-mark thumb ([squiggly]).
enum MediaProgressBarStyle { classic, wavy, slim, squiggly }
/// The frosted-glass bottom bar's blur sigma and surface opacity, in three
/// ready-made strengths (Settings, Appearance). Moving either slider off all
/// three leaves no preset selected (a custom mix).
enum FrostedGlassPreset {
less(blur: 10, opacity: 0.88, label: 'Less'),
standard(blur: 20, opacity: 0.72, label: 'Default'),
more(blur: 32, opacity: 0.55, label: 'More');
final double blur;
final double opacity;
final String label;
const FrostedGlassPreset({
required this.blur,
required this.opacity,
required this.label,
});
}
const double minFrostedBlur = 0;
const double maxFrostedBlur = 40;
const double minFrostedOpacity = 0.3;
const double maxFrostedOpacity = 1.0;
/// Global (not per-account) UI settings: theme, bottom-nav appearance, tab
/// configuration, swipe actions. Independent of login state - these mean
/// the same thing whether any account is signed in or not, unlike
@@ -39,6 +63,8 @@ class SettingsController extends ChangeNotifier {
static const _prefFabStyle = 'ui_fab_style';
static const _prefBottomBarStyle = 'ui_bottom_bar_style';
static const _prefBottomBarFrosted = 'ui_bottom_bar_frosted';
static const _prefFrostedBlur = 'ui_bottom_bar_frosted_blur';
static const _prefFrostedOpacity = 'ui_bottom_bar_frosted_opacity';
static const _prefNavMenuStyle = 'ui_nav_menu_style';
static const _prefSearchInBottomBar = 'ui_search_in_bottom_bar';
static const _prefAmoledDark = 'ui_amoled_dark';
@@ -53,6 +79,8 @@ class SettingsController extends ChangeNotifier {
NooBottomBarStyle _bottomBarStyle = NooBottomBarStyle.attached;
NooNavMenuStyle _navMenuStyle = NooNavMenuStyle.drawer;
bool _bottomBarFrosted = false;
double _frostedBlur = FrostedGlassPreset.standard.blur;
double _frostedOpacity = FrostedGlassPreset.standard.opacity;
bool _searchInBottomBar = false;
bool _useDynamicColor = true;
bool _amoledDark = false;
@@ -98,6 +126,14 @@ class SettingsController extends ChangeNotifier {
ThemeMode get themeMode => _themeMode;
NooBottomBarStyle get bottomBarStyle => _bottomBarStyle;
bool get bottomBarFrosted => _bottomBarFrosted;
double get bottomBarFrostedBlur => _frostedBlur;
double get bottomBarFrostedOpacity => _frostedOpacity;
/// The preset the current blur/opacity match exactly, or null when the
/// sliders have been moved to something custom.
FrostedGlassPreset? get frostedPreset => FrostedGlassPreset.values
.where((p) => p.blur == _frostedBlur && p.opacity == _frostedOpacity)
.firstOrNull;
NooNavMenuStyle get navMenuStyle => _navMenuStyle;
bool get searchInBottomBar => _searchInBottomBar;
bool get useDynamicColor => _useDynamicColor;
@@ -160,6 +196,15 @@ class SettingsController extends ChangeNotifier {
}
_bottomBarFrosted =
prefs.getBool(_prefBottomBarFrosted) ?? _bottomBarFrosted;
_frostedBlur = (prefs.getDouble(_prefFrostedBlur) ?? _frostedBlur).clamp(
minFrostedBlur,
maxFrostedBlur,
);
_frostedOpacity =
(prefs.getDouble(_prefFrostedOpacity) ?? _frostedOpacity).clamp(
minFrostedOpacity,
maxFrostedOpacity,
);
_searchInBottomBar =
prefs.getBool(_prefSearchInBottomBar) ?? _searchInBottomBar;
_useDynamicColor =
@@ -318,6 +363,27 @@ class SettingsController extends ChangeNotifier {
_prefsFuture.then((p) => p.setString(_prefBottomBarStyle, style.name));
}
void setFrostedBlur(double value) {
final v = value.clamp(minFrostedBlur, maxFrostedBlur);
if (_frostedBlur == v) return;
_frostedBlur = v;
notifyListeners();
_prefsFuture.then((p) => p.setDouble(_prefFrostedBlur, v));
}
void setFrostedOpacity(double value) {
final v = value.clamp(minFrostedOpacity, maxFrostedOpacity);
if (_frostedOpacity == v) return;
_frostedOpacity = v;
notifyListeners();
_prefsFuture.then((p) => p.setDouble(_prefFrostedOpacity, v));
}
void setFrostedPreset(FrostedGlassPreset preset) {
setFrostedBlur(preset.blur);
setFrostedOpacity(preset.opacity);
}
void setBottomBarFrosted(bool value) {
if (_bottomBarFrosted == value) return;
_bottomBarFrosted = value;
+6
View File
@@ -17,6 +17,8 @@ class BottomNavBar extends StatelessWidget {
final NooNavStyle style;
final NooBottomBarStyle barStyle;
final bool frosted;
final double frostedBlur;
final double frostedOpacity;
final List<AppTab> tabs;
final int selectedIndex;
final ValueChanged<int> onDestinationSelected;
@@ -27,6 +29,8 @@ class BottomNavBar extends StatelessWidget {
required this.style,
this.barStyle = NooBottomBarStyle.attached,
this.frosted = false,
this.frostedBlur = NooBottomBar.defaultFrostedBlur,
this.frostedOpacity = NooBottomBar.defaultFrostedOpacity,
required this.tabs,
required this.selectedIndex,
required this.onDestinationSelected,
@@ -39,6 +43,8 @@ class BottomNavBar extends StatelessWidget {
style: style,
barStyle: barStyle,
frosted: frosted,
frostedBlur: frostedBlur,
frostedOpacity: frostedOpacity,
destinations: [
for (final tab in tabs)
NooNavDestination(icon: tab.icon, label: tab.label),
+51
View File
@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import '../../../theme/design_tokens.dart';
/// A continuous (or [divisions]-stepped) value slider in the design system's
/// colors: accent fill on a `surface3` track, accent thumb. Data-agnostic -
/// the caller owns [value] and formats any readout beside it.
class NooSlider extends StatelessWidget {
final double value;
final double min;
final double max;
final int? divisions;
final ValueChanged<double>? onChanged;
final String? semanticLabel;
const NooSlider({
super.key,
required this.value,
required this.min,
required this.max,
this.divisions,
this.onChanged,
this.semanticLabel,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 4,
activeTrackColor: colors.accent,
inactiveTrackColor: colors.surface3,
thumbColor: colors.accent,
overlayColor: colors.accent.withValues(alpha: 0.12),
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 9),
trackShape: const RoundedRectSliderTrackShape(),
showValueIndicator: ShowValueIndicator.never,
),
child: Slider(
value: value.clamp(min, max),
min: min,
max: max,
divisions: divisions,
semanticFormatterCallback: semanticLabel == null
? null
: (v) => '$semanticLabel ${v.toStringAsFixed(2)}',
onChanged: onChanged,
),
);
}
}
+28 -12
View File
@@ -14,11 +14,6 @@ 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].
@@ -71,6 +66,12 @@ class NooBottomBar extends StatelessWidget {
final NooNavStyle style;
final NooBottomBarStyle barStyle;
final bool frosted;
/// Backdrop blur sigma and surface-fill opacity while [frosted] (user-
/// adjustable in Settings; the defaults match [FrostedGlassContainer]'s
/// blur).
final double frostedBlur;
final double frostedOpacity;
final List<NooNavDestination> destinations;
final int selectedIndex;
final ValueChanged<int> onSelected;
@@ -82,6 +83,8 @@ class NooBottomBar extends StatelessWidget {
required this.style,
this.barStyle = NooBottomBarStyle.attached,
this.frosted = false,
this.frostedBlur = defaultFrostedBlur,
this.frostedOpacity = defaultFrostedOpacity,
required this.destinations,
required this.selectedIndex,
required this.onSelected,
@@ -99,6 +102,9 @@ class NooBottomBar extends StatelessWidget {
return barStyle == NooBottomBarStyle.floating ? 64 : 80;
}
static const double defaultFrostedBlur = 20;
static const double defaultFrostedOpacity = 0.72;
/// 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).
@@ -144,7 +150,9 @@ class NooBottomBar extends StatelessWidget {
),
child: row,
);
final pill = frosted ? _blurred(pillBox, pillRadius) : pillBox;
final pill = frosted
? _blurred(pillBox, pillRadius, frostedBlur)
: pillBox;
return SafeArea(
top: false,
child: Padding(
@@ -176,6 +184,8 @@ class NooBottomBar extends StatelessWidget {
onTap: onSearchTap!,
size: barHeight,
frosted: frosted,
frostedBlur: frostedBlur,
frostedOpacity: frostedOpacity,
),
],
),
@@ -195,11 +205,11 @@ class NooBottomBar extends StatelessWidget {
child: SizedBox(height: barHeight, child: row),
),
);
return frosted ? _blurred(bar, BorderRadius.zero) : bar;
return frosted ? _blurred(bar, BorderRadius.zero, frostedBlur) : bar;
}
Color _fill(NooColors colors) => frosted
? colors.surface.withValues(alpha: _kFrostedAlpha)
? colors.surface.withValues(alpha: frostedOpacity)
: colors.surface;
Widget _buildIosRow({NooNavDestination? trailingSearch}) {
@@ -286,10 +296,10 @@ 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(
Widget _blurred(Widget child, BorderRadius radius, double sigma) => ClipRRect(
borderRadius: radius,
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: _kFrostedSigma, sigmaY: _kFrostedSigma),
filter: ImageFilter.blur(sigmaX: sigma, sigmaY: sigma),
child: child,
),
);
@@ -302,12 +312,16 @@ class _SearchSatellite extends StatelessWidget {
final VoidCallback onTap;
final double size;
final bool frosted;
final double frostedBlur;
final double frostedOpacity;
const _SearchSatellite({
required this.destination,
required this.onTap,
required this.size,
required this.frosted,
required this.frostedBlur,
required this.frostedOpacity,
});
@override
@@ -331,7 +345,7 @@ class _SearchSatellite extends StatelessWidget {
alignment: Alignment.center,
decoration: BoxDecoration(
color: frosted
? colors.surface.withValues(alpha: _kFrostedAlpha)
? colors.surface.withValues(alpha: frostedOpacity)
: colors.surface,
border: Border.all(color: colors.line),
shape: BoxShape.circle,
@@ -339,7 +353,9 @@ class _SearchSatellite extends StatelessWidget {
),
child: Icon(destination.icon, size: 24, color: colors.fg1),
);
return frosted ? _blurred(disc, BorderRadius.circular(size / 2)) : disc;
return frosted
? _blurred(disc, BorderRadius.circular(size / 2), frostedBlur)
: disc;
}
}
@@ -5,6 +5,7 @@ import '../../providers/settings_controller.dart';
import '../../theme/app_theme.dart';
import '../../theme/design_tokens.dart';
import '../noo/core/noo_segmented_control.dart';
import '../noo/core/noo_slider.dart';
import '../noo/core/noo_toggle.dart';
import '../noo/lists/noo_settings_row.dart';
import '../noo/nav/noo_nav_style.dart';
@@ -64,6 +65,7 @@ class SettingsAppearanceSection extends StatelessWidget {
onChanged: settings.setBottomBarFrosted,
),
),
if (settings.bottomBarFrosted) _FrostedGlassRow(settings: settings),
_NavMenuStyleRow(settings: settings),
NooSettingsRow(
icon: LucideIcons.search,
@@ -251,6 +253,117 @@ class _BottomBarStyleRow extends StatelessWidget {
}
}
/// How strong the frosted-glass bottom bar looks: three presets for a quick
/// pick, and the blur / opacity sliders they set, for anything in between.
/// Only shown while frosted glass is on.
class _FrostedGlassRow extends StatelessWidget {
final SettingsController settings;
const _FrostedGlassRow({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.sparkles, size: 20, color: colors.fg2),
const SizedBox(width: 14),
Text(
'Frost strength',
style: NooText.bodyL.copyWith(color: colors.fg1),
),
],
),
const SizedBox(height: 12),
NooSegmentedControl<FrostedGlassPreset?>(
fill: true,
// No segment selected once the sliders no longer match a preset.
value: settings.frostedPreset,
onChanged: (preset) {
if (preset != null) settings.setFrostedPreset(preset);
},
options: [
for (final preset in FrostedGlassPreset.values)
NooSegmentOption<FrostedGlassPreset?>(
value: preset,
label: preset.label,
),
],
),
const SizedBox(height: 8),
_SliderLine(
label: 'Blur',
readout: settings.bottomBarFrostedBlur.round().toString(),
slider: NooSlider(
value: settings.bottomBarFrostedBlur,
min: minFrostedBlur,
max: maxFrostedBlur,
semanticLabel: 'Blur',
onChanged: settings.setFrostedBlur,
),
),
_SliderLine(
label: 'Opacity',
readout: '${(settings.bottomBarFrostedOpacity * 100).round()}%',
slider: NooSlider(
value: settings.bottomBarFrostedOpacity,
min: minFrostedOpacity,
max: maxFrostedOpacity,
semanticLabel: 'Opacity',
onChanged: settings.setFrostedOpacity,
),
),
],
),
),
);
}
}
class _SliderLine extends StatelessWidget {
final String label;
final String readout;
final Widget slider;
const _SliderLine({
required this.label,
required this.readout,
required this.slider,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Row(
children: [
SizedBox(
width: 64,
child: Text(label, style: NooText.body.copyWith(color: colors.fg2)),
),
Expanded(child: slider),
SizedBox(
width: 44,
child: Text(
readout,
textAlign: TextAlign.end,
style: NooText.body.copyWith(color: colors.fg3),
),
),
],
);
}
}
/// "Navigation menu" (Settings → Appearance): which widget opens hidden
/// tabs + Settings - the original `menu` icon that opens a left `Drawer`,
/// or the avatar button instead (opens `showAvatarMenu`, a dropdown