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
@@ -86,7 +86,7 @@ Catalog:
|
|||||||
|
|
||||||
| Folder | Components |
|
| Folder | Components |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `core/` | `NooButton`, `NooFab`, `NooChip`, `NooSegmentedControl`, `NooToggle`, `NooSearchField`, `NooAvatar`, `NooBadge`, `NooProgressBar` |
|
| `core/` | `NooButton`, `NooFab`, `NooChip`, `NooSegmentedControl`, `NooToggle`, `NooSlider`, `NooSearchField`, `NooAvatar`, `NooBadge`, `NooProgressBar` |
|
||||||
| `lists/` | `NooGroupedList`, `NooSettingsRow`, `NooTabOrderRow`, `NooBanner`, `NooInfoNote` (tinted info callout; `SettingsSection(notice:)`), `NooSummaryCard`, `NooSelectionBar` |
|
| `lists/` | `NooGroupedList`, `NooSettingsRow`, `NooTabOrderRow`, `NooBanner`, `NooInfoNote` (tinted info callout; `SettingsSection(notice:)`), `NooSummaryCard`, `NooSelectionBar` |
|
||||||
| `files/` | `NooFileKind` (spec §1.2 tiles; `NooFileKind.from(name:, mimeType:, isDirectory:)`), `NooFileTile`, `NooStatusIcon`/`NooSyncStatus`, `NooFileRow` (mobile 64px), `NooFileTableHeader`/`NooFileTableRow` (desktop), `NooSwipeAction` |
|
| `files/` | `NooFileKind` (spec §1.2 tiles; `NooFileKind.from(name:, mimeType:, isDirectory:)`), `NooFileTile`, `NooStatusIcon`/`NooSyncStatus`, `NooFileRow` (mobile 64px), `NooFileTableHeader`/`NooFileTableRow` (desktop), `NooSwipeAction` |
|
||||||
| `media/` | `NooGridCard`, `NooPhotoTile` (video badge, selection), `NooPhotoGroupHeader`/`NooPhotoGrid` (sliver, or `.box`), `NooActivityItem`, `NooStatCard` |
|
| `media/` | `NooGridCard`, `NooPhotoTile` (video badge, selection), `NooPhotoGroupHeader`/`NooPhotoGrid` (sliver, or `.box`), `NooActivityItem`, `NooStatCard` |
|
||||||
@@ -255,9 +255,16 @@ blocks are noted where they matter:
|
|||||||
only the attached bar still has a distinct iOS row. A separate, orthogonal
|
only the attached bar still has a distinct iOS row. A separate, orthogonal
|
||||||
`SettingsController.bottomBarFrosted` toggle (Settings → Appearance →
|
`SettingsController.bottomBarFrosted` toggle (Settings → Appearance →
|
||||||
"Frosted glass bottom bar", off by default, both platforms and both bar
|
"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
|
styles) swaps the bar's solid `surface` for a translucent one over a
|
||||||
20-sigma `BackdropFilter` (same sigma as `FrostedGlassContainer`) and drops
|
`BackdropFilter` blur and drops
|
||||||
`nooDialogShadow` (it would show through the glass). A frosted bar only
|
`nooDialogShadow` (it would show through the glass). Strength is user-set:
|
||||||
|
`FrostedGlassPreset` Less (blur 10 / opacity 0.88), Default (20 / 0.72, the
|
||||||
|
old fixed values) and More (32 / 0.55), plus Blur (0-40) and Opacity
|
||||||
|
(0.3-1.0) sliders (`NooSlider`, `core/`) in Settings -> Appearance, shown
|
||||||
|
only while frosted glass is on; both persist
|
||||||
|
(`ui_bottom_bar_frosted_blur`/`_opacity`) and reach the bar as
|
||||||
|
`NooBottomBar.frostedBlur`/`frostedOpacity`. No preset is highlighted once
|
||||||
|
the sliders are moved off all three. A frosted bar only
|
||||||
reads as glass with content behind it, so the shell uses
|
reads as glass with content behind it, so the shell uses
|
||||||
`NooBottomBar.drawsBehindBody(barStyle, frosted)` for `extendBody`, and
|
`NooBottomBar.drawsBehindBody(barStyle, frosted)` for `extendBody`, and
|
||||||
`bottomBarClearance` (and Files' own trailing sliver) reserve the bar's
|
`bottomBarClearance` (and Files' own trailing sliver) reserve the bar's
|
||||||
|
|||||||
@@ -564,6 +564,8 @@ class _MainShellViewState extends State<MainShellView> {
|
|||||||
style: navStyle,
|
style: navStyle,
|
||||||
barStyle: bottomBarStyle,
|
barStyle: bottomBarStyle,
|
||||||
frosted: settings.bottomBarFrosted,
|
frosted: settings.bottomBarFrosted,
|
||||||
|
frostedBlur: settings.bottomBarFrostedBlur,
|
||||||
|
frostedOpacity: settings.bottomBarFrostedOpacity,
|
||||||
tabs: pinnedTabs,
|
tabs: pinnedTabs,
|
||||||
selectedIndex: pinnedIndex,
|
selectedIndex: pinnedIndex,
|
||||||
onSearchTap: showBottomBarSearch ? () => openSearch(context) : null,
|
onSearchTap: showBottomBarSearch ? () => openSearch(context) : null,
|
||||||
|
|||||||
@@ -21,6 +21,30 @@ enum FabStyle { auto, mini, expanded }
|
|||||||
/// animated wave with a tick-mark thumb ([squiggly]).
|
/// animated wave with a tick-mark thumb ([squiggly]).
|
||||||
enum MediaProgressBarStyle { classic, wavy, slim, 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
|
/// Global (not per-account) UI settings: theme, bottom-nav appearance, tab
|
||||||
/// configuration, swipe actions. Independent of login state - these mean
|
/// configuration, swipe actions. Independent of login state - these mean
|
||||||
/// the same thing whether any account is signed in or not, unlike
|
/// 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 _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 _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 _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';
|
||||||
@@ -53,6 +79,8 @@ class SettingsController extends ChangeNotifier {
|
|||||||
NooBottomBarStyle _bottomBarStyle = NooBottomBarStyle.attached;
|
NooBottomBarStyle _bottomBarStyle = NooBottomBarStyle.attached;
|
||||||
NooNavMenuStyle _navMenuStyle = NooNavMenuStyle.drawer;
|
NooNavMenuStyle _navMenuStyle = NooNavMenuStyle.drawer;
|
||||||
bool _bottomBarFrosted = false;
|
bool _bottomBarFrosted = false;
|
||||||
|
double _frostedBlur = FrostedGlassPreset.standard.blur;
|
||||||
|
double _frostedOpacity = FrostedGlassPreset.standard.opacity;
|
||||||
bool _searchInBottomBar = false;
|
bool _searchInBottomBar = false;
|
||||||
bool _useDynamicColor = true;
|
bool _useDynamicColor = true;
|
||||||
bool _amoledDark = false;
|
bool _amoledDark = false;
|
||||||
@@ -98,6 +126,14 @@ class SettingsController extends ChangeNotifier {
|
|||||||
ThemeMode get themeMode => _themeMode;
|
ThemeMode get themeMode => _themeMode;
|
||||||
NooBottomBarStyle get bottomBarStyle => _bottomBarStyle;
|
NooBottomBarStyle get bottomBarStyle => _bottomBarStyle;
|
||||||
bool get bottomBarFrosted => _bottomBarFrosted;
|
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;
|
NooNavMenuStyle get navMenuStyle => _navMenuStyle;
|
||||||
bool get searchInBottomBar => _searchInBottomBar;
|
bool get searchInBottomBar => _searchInBottomBar;
|
||||||
bool get useDynamicColor => _useDynamicColor;
|
bool get useDynamicColor => _useDynamicColor;
|
||||||
@@ -160,6 +196,15 @@ class SettingsController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
_bottomBarFrosted =
|
_bottomBarFrosted =
|
||||||
prefs.getBool(_prefBottomBarFrosted) ?? _bottomBarFrosted;
|
prefs.getBool(_prefBottomBarFrosted) ?? _bottomBarFrosted;
|
||||||
|
_frostedBlur = (prefs.getDouble(_prefFrostedBlur) ?? _frostedBlur).clamp(
|
||||||
|
minFrostedBlur,
|
||||||
|
maxFrostedBlur,
|
||||||
|
);
|
||||||
|
_frostedOpacity =
|
||||||
|
(prefs.getDouble(_prefFrostedOpacity) ?? _frostedOpacity).clamp(
|
||||||
|
minFrostedOpacity,
|
||||||
|
maxFrostedOpacity,
|
||||||
|
);
|
||||||
_searchInBottomBar =
|
_searchInBottomBar =
|
||||||
prefs.getBool(_prefSearchInBottomBar) ?? _searchInBottomBar;
|
prefs.getBool(_prefSearchInBottomBar) ?? _searchInBottomBar;
|
||||||
_useDynamicColor =
|
_useDynamicColor =
|
||||||
@@ -318,6 +363,27 @@ class SettingsController extends ChangeNotifier {
|
|||||||
_prefsFuture.then((p) => p.setString(_prefBottomBarStyle, style.name));
|
_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) {
|
void setBottomBarFrosted(bool value) {
|
||||||
if (_bottomBarFrosted == value) return;
|
if (_bottomBarFrosted == value) return;
|
||||||
_bottomBarFrosted = value;
|
_bottomBarFrosted = value;
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ class BottomNavBar extends StatelessWidget {
|
|||||||
final NooNavStyle style;
|
final NooNavStyle style;
|
||||||
final NooBottomBarStyle barStyle;
|
final NooBottomBarStyle barStyle;
|
||||||
final bool frosted;
|
final bool frosted;
|
||||||
|
final double frostedBlur;
|
||||||
|
final double frostedOpacity;
|
||||||
final List<AppTab> tabs;
|
final List<AppTab> tabs;
|
||||||
final int selectedIndex;
|
final int selectedIndex;
|
||||||
final ValueChanged<int> onDestinationSelected;
|
final ValueChanged<int> onDestinationSelected;
|
||||||
@@ -27,6 +29,8 @@ class BottomNavBar extends StatelessWidget {
|
|||||||
required this.style,
|
required this.style,
|
||||||
this.barStyle = NooBottomBarStyle.attached,
|
this.barStyle = NooBottomBarStyle.attached,
|
||||||
this.frosted = false,
|
this.frosted = false,
|
||||||
|
this.frostedBlur = NooBottomBar.defaultFrostedBlur,
|
||||||
|
this.frostedOpacity = NooBottomBar.defaultFrostedOpacity,
|
||||||
required this.tabs,
|
required this.tabs,
|
||||||
required this.selectedIndex,
|
required this.selectedIndex,
|
||||||
required this.onDestinationSelected,
|
required this.onDestinationSelected,
|
||||||
@@ -39,6 +43,8 @@ class BottomNavBar extends StatelessWidget {
|
|||||||
style: style,
|
style: style,
|
||||||
barStyle: barStyle,
|
barStyle: barStyle,
|
||||||
frosted: frosted,
|
frosted: frosted,
|
||||||
|
frostedBlur: frostedBlur,
|
||||||
|
frostedOpacity: frostedOpacity,
|
||||||
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),
|
||||||
|
|||||||
@@ -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 _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].
|
||||||
@@ -71,6 +66,12 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
final NooNavStyle style;
|
final NooNavStyle style;
|
||||||
final NooBottomBarStyle barStyle;
|
final NooBottomBarStyle barStyle;
|
||||||
final bool frosted;
|
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 List<NooNavDestination> destinations;
|
||||||
final int selectedIndex;
|
final int selectedIndex;
|
||||||
final ValueChanged<int> onSelected;
|
final ValueChanged<int> onSelected;
|
||||||
@@ -82,6 +83,8 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
required this.style,
|
required this.style,
|
||||||
this.barStyle = NooBottomBarStyle.attached,
|
this.barStyle = NooBottomBarStyle.attached,
|
||||||
this.frosted = false,
|
this.frosted = false,
|
||||||
|
this.frostedBlur = defaultFrostedBlur,
|
||||||
|
this.frostedOpacity = defaultFrostedOpacity,
|
||||||
required this.destinations,
|
required this.destinations,
|
||||||
required this.selectedIndex,
|
required this.selectedIndex,
|
||||||
required this.onSelected,
|
required this.onSelected,
|
||||||
@@ -99,6 +102,9 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
return barStyle == NooBottomBarStyle.floating ? 64 : 80;
|
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
|
/// Whether the host `Scaffold` must draw its body behind the bar
|
||||||
/// (`extendBody`) - true for floating (its transparent margin) and for any
|
/// (`extendBody`) - true for floating (its transparent margin) and for any
|
||||||
/// frosted bar (there's nothing to blur otherwise).
|
/// frosted bar (there's nothing to blur otherwise).
|
||||||
@@ -144,7 +150,9 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: row,
|
child: row,
|
||||||
);
|
);
|
||||||
final pill = frosted ? _blurred(pillBox, pillRadius) : pillBox;
|
final pill = frosted
|
||||||
|
? _blurred(pillBox, pillRadius, frostedBlur)
|
||||||
|
: pillBox;
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -176,6 +184,8 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
onTap: onSearchTap!,
|
onTap: onSearchTap!,
|
||||||
size: barHeight,
|
size: barHeight,
|
||||||
frosted: frosted,
|
frosted: frosted,
|
||||||
|
frostedBlur: frostedBlur,
|
||||||
|
frostedOpacity: frostedOpacity,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -195,11 +205,11 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
child: SizedBox(height: barHeight, child: row),
|
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
|
Color _fill(NooColors colors) => frosted
|
||||||
? colors.surface.withValues(alpha: _kFrostedAlpha)
|
? colors.surface.withValues(alpha: frostedOpacity)
|
||||||
: colors.surface;
|
: colors.surface;
|
||||||
|
|
||||||
Widget _buildIosRow({NooNavDestination? trailingSearch}) {
|
Widget _buildIosRow({NooNavDestination? trailingSearch}) {
|
||||||
@@ -286,10 +296,10 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
|
|
||||||
/// Clips [child] to [radius] and blurs whatever is drawn behind it - the
|
/// Clips [child] to [radius] and blurs whatever is drawn behind it - the
|
||||||
/// frosted bar's backdrop. [child] supplies the translucent fill on top.
|
/// 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,
|
borderRadius: radius,
|
||||||
child: BackdropFilter(
|
child: BackdropFilter(
|
||||||
filter: ImageFilter.blur(sigmaX: _kFrostedSigma, sigmaY: _kFrostedSigma),
|
filter: ImageFilter.blur(sigmaX: sigma, sigmaY: sigma),
|
||||||
child: child,
|
child: child,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -302,12 +312,16 @@ class _SearchSatellite extends StatelessWidget {
|
|||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
final double size;
|
final double size;
|
||||||
final bool frosted;
|
final bool frosted;
|
||||||
|
final double frostedBlur;
|
||||||
|
final double frostedOpacity;
|
||||||
|
|
||||||
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,
|
required this.frosted,
|
||||||
|
required this.frostedBlur,
|
||||||
|
required this.frostedOpacity,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -331,7 +345,7 @@ class _SearchSatellite extends StatelessWidget {
|
|||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: frosted
|
color: frosted
|
||||||
? colors.surface.withValues(alpha: _kFrostedAlpha)
|
? colors.surface.withValues(alpha: frostedOpacity)
|
||||||
: colors.surface,
|
: colors.surface,
|
||||||
border: Border.all(color: colors.line),
|
border: Border.all(color: colors.line),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
@@ -339,7 +353,9 @@ class _SearchSatellite extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: Icon(destination.icon, size: 24, color: colors.fg1),
|
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/app_theme.dart';
|
||||||
import '../../theme/design_tokens.dart';
|
import '../../theme/design_tokens.dart';
|
||||||
import '../noo/core/noo_segmented_control.dart';
|
import '../noo/core/noo_segmented_control.dart';
|
||||||
|
import '../noo/core/noo_slider.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/nav/noo_nav_style.dart';
|
||||||
@@ -64,6 +65,7 @@ class SettingsAppearanceSection extends StatelessWidget {
|
|||||||
onChanged: settings.setBottomBarFrosted,
|
onChanged: settings.setBottomBarFrosted,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (settings.bottomBarFrosted) _FrostedGlassRow(settings: settings),
|
||||||
_NavMenuStyleRow(settings: settings),
|
_NavMenuStyleRow(settings: settings),
|
||||||
NooSettingsRow(
|
NooSettingsRow(
|
||||||
icon: LucideIcons.search,
|
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
|
/// "Navigation menu" (Settings → Appearance): which widget opens hidden
|
||||||
/// tabs + Settings - the original `menu` icon that opens a left `Drawer`,
|
/// tabs + Settings - the original `menu` icon that opens a left `Drawer`,
|
||||||
/// or the avatar button instead (opens `showAvatarMenu`, a dropdown
|
/// or the avatar button instead (opens `showAvatarMenu`, a dropdown
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:noo/providers/settings_controller.dart';
|
||||||
|
|
||||||
|
/// The frosted-glass bottom bar's blur/opacity settings and their presets.
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
setUp(() => SharedPreferences.setMockInitialValues({}));
|
||||||
|
|
||||||
|
Future<SettingsController> build() async {
|
||||||
|
final settings = SettingsController();
|
||||||
|
// `_load` is async and not exposed; one event-loop turn lets it finish.
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('starts on the Default preset', () async {
|
||||||
|
final settings = await build();
|
||||||
|
expect(settings.frostedPreset, FrostedGlassPreset.standard);
|
||||||
|
expect(settings.bottomBarFrostedBlur, 20);
|
||||||
|
expect(settings.bottomBarFrostedOpacity, 0.72);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('presets set both values and are recognised again', () async {
|
||||||
|
final settings = await build();
|
||||||
|
for (final preset in FrostedGlassPreset.values) {
|
||||||
|
settings.setFrostedPreset(preset);
|
||||||
|
expect(settings.bottomBarFrostedBlur, preset.blur);
|
||||||
|
expect(settings.bottomBarFrostedOpacity, preset.opacity);
|
||||||
|
expect(settings.frostedPreset, preset);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('presets run from lighter to stronger frosting', () {
|
||||||
|
expect(
|
||||||
|
FrostedGlassPreset.less.blur,
|
||||||
|
lessThan(FrostedGlassPreset.standard.blur),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
FrostedGlassPreset.standard.blur,
|
||||||
|
lessThan(FrostedGlassPreset.more.blur),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
FrostedGlassPreset.less.opacity,
|
||||||
|
greaterThan(FrostedGlassPreset.standard.opacity),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
FrostedGlassPreset.standard.opacity,
|
||||||
|
greaterThan(FrostedGlassPreset.more.opacity),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('moving a slider off every preset leaves none selected', () async {
|
||||||
|
final settings = await build();
|
||||||
|
settings.setFrostedBlur(27);
|
||||||
|
expect(settings.frostedPreset, isNull);
|
||||||
|
settings.setFrostedBlur(FrostedGlassPreset.standard.blur);
|
||||||
|
expect(settings.frostedPreset, FrostedGlassPreset.standard);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('values are clamped to their ranges', () async {
|
||||||
|
final settings = await build();
|
||||||
|
settings
|
||||||
|
..setFrostedBlur(500)
|
||||||
|
..setFrostedOpacity(0);
|
||||||
|
expect(settings.bottomBarFrostedBlur, maxFrostedBlur);
|
||||||
|
expect(settings.bottomBarFrostedOpacity, minFrostedOpacity);
|
||||||
|
settings
|
||||||
|
..setFrostedBlur(-3)
|
||||||
|
..setFrostedOpacity(9);
|
||||||
|
expect(settings.bottomBarFrostedBlur, minFrostedBlur);
|
||||||
|
expect(settings.bottomBarFrostedOpacity, maxFrostedOpacity);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('saved values are restored (and clamped) on the next launch', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({
|
||||||
|
'ui_bottom_bar_frosted_blur': 32.0,
|
||||||
|
'ui_bottom_bar_frosted_opacity': 7.0,
|
||||||
|
});
|
||||||
|
final settings = await build();
|
||||||
|
expect(settings.bottomBarFrostedBlur, 32);
|
||||||
|
expect(settings.bottomBarFrostedOpacity, maxFrostedOpacity);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import 'package:noo/widgets/noo/core/noo_chip.dart';
|
|||||||
import 'package:noo/widgets/noo/core/noo_fab.dart';
|
import 'package:noo/widgets/noo/core/noo_fab.dart';
|
||||||
import 'package:noo/widgets/noo/core/noo_progress_bar.dart';
|
import 'package:noo/widgets/noo/core/noo_progress_bar.dart';
|
||||||
import 'package:noo/widgets/noo/core/noo_search_field.dart';
|
import 'package:noo/widgets/noo/core/noo_search_field.dart';
|
||||||
|
import 'package:noo/widgets/noo/core/noo_slider.dart';
|
||||||
import 'package:noo/widgets/noo/core/noo_segmented_control.dart';
|
import 'package:noo/widgets/noo/core/noo_segmented_control.dart';
|
||||||
import 'package:noo/widgets/noo/core/noo_toggle.dart';
|
import 'package:noo/widgets/noo/core/noo_toggle.dart';
|
||||||
import 'package:noo/widgets/noo/nav/noo_nav_style.dart';
|
import 'package:noo/widgets/noo/nav/noo_nav_style.dart';
|
||||||
@@ -98,6 +99,35 @@ void main() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('NooSlider', () {
|
||||||
|
testNooWidgets('dragging reports a value within min..max', (
|
||||||
|
tester,
|
||||||
|
theme,
|
||||||
|
c,
|
||||||
|
) async {
|
||||||
|
var value = 10.0;
|
||||||
|
await pumpNoo(
|
||||||
|
tester,
|
||||||
|
StatefulBuilder(
|
||||||
|
builder: (context, setState) => SizedBox(
|
||||||
|
width: 300,
|
||||||
|
child: NooSlider(
|
||||||
|
value: value,
|
||||||
|
min: 0,
|
||||||
|
max: 40,
|
||||||
|
onChanged: (v) => setState(() => value = v),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
theme: theme,
|
||||||
|
);
|
||||||
|
await tester.drag(find.byType(Slider), const Offset(150, 0));
|
||||||
|
await tester.pump();
|
||||||
|
expect(value, greaterThan(10));
|
||||||
|
expect(value, lessThanOrEqualTo(40));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('NooAvatar', () {
|
group('NooAvatar', () {
|
||||||
testNooWidgets('current user uses accent-soft/accent-text', (
|
testNooWidgets('current user uses accent-soft/accent-text', (
|
||||||
tester,
|
tester,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:ui' show ImageFilter;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
@@ -62,6 +63,44 @@ void main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
testNooWidgets('frosted bar uses the given blur sigma and fill opacity', (
|
||||||
|
tester,
|
||||||
|
theme,
|
||||||
|
c,
|
||||||
|
) async {
|
||||||
|
await pumpNoo(
|
||||||
|
tester,
|
||||||
|
SizedBox(
|
||||||
|
width: 360,
|
||||||
|
height: 120,
|
||||||
|
child: NooBottomBar(
|
||||||
|
style: NooNavStyle.android,
|
||||||
|
frosted: true,
|
||||||
|
frostedBlur: 31,
|
||||||
|
frostedOpacity: 0.5,
|
||||||
|
destinations: _destinations,
|
||||||
|
selectedIndex: 0,
|
||||||
|
onSelected: (_) {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
theme: theme,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
tester.widget<BackdropFilter>(find.byType(BackdropFilter)).filter,
|
||||||
|
ImageFilter.blur(sigmaX: 31, sigmaY: 31),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.byWidgetPredicate(
|
||||||
|
(w) =>
|
||||||
|
w is Container &&
|
||||||
|
w.decoration is BoxDecoration &&
|
||||||
|
(w.decoration as BoxDecoration).color ==
|
||||||
|
c.surface.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('drawsBehindBody: floating or frosted', () {
|
test('drawsBehindBody: floating or frosted', () {
|
||||||
expect(
|
expect(
|
||||||
NooBottomBar.drawsBehindBody(NooBottomBarStyle.attached, false),
|
NooBottomBar.drawsBehindBody(NooBottomBarStyle.attached, false),
|
||||||
|
|||||||
Reference in New Issue
Block a user