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:
co-authored by
Claude Sonnet 5.5
parent
0c063c1fb4
commit
0cda4a1241
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user