From ce13e29b71e0c40b6c377f53a00dccf6ef4756e1 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:42:20 +0700 Subject: [PATCH] feat(appearance): add glass clarity controls and directional highlights --- lib/l10n/arb/app_en.arb | 4 + lib/l10n/arb/app_id.arb | 4 + lib/models/theme_settings.dart | 23 +- lib/providers/theme_provider.dart | 12 + .../settings/appearance_settings_page.dart | 93 +++++++ lib/screens/upgrade_intro_screen.dart | 1 + lib/theme/dynamic_color_wrapper.dart | 2 + lib/theme/mornye_theme.dart | 33 ++- lib/widgets/mornye_chrome.dart | 239 +++++++++++------- test/appearance_settings_test.dart | 66 ++++- test/mornye_bottom_bar_test.dart | 162 ++++++------ test/mornye_context_menu_test.dart | 112 ++++---- 12 files changed, 526 insertions(+), 225 deletions(-) diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 3af51f9d..09577b64 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -70,6 +70,10 @@ "appearanceAccentBlue": "Blue", "appearanceAccentPurple": "Purple", "appearanceAccentPink": "Pink", + "appearanceGlassClarity": "Glass clarity", + "appearanceGlassClaritySubtitle": "Adjust transparency in Mornye menus and navigation. Text contrast is preserved.", + "appearanceGlassTinted": "Tinted", + "appearanceGlassClear": "Clear", "appearanceUseSystemFont": "Use system font", "appearanceUseSystemFontSubtitle": "Use your device’s default typeface for app text.", "@appearanceStyle": { diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 9098e3fc..7507882f 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -70,6 +70,10 @@ "appearanceAccentBlue": "Biru", "appearanceAccentPurple": "Ungu", "appearanceAccentPink": "Merah muda", + "appearanceGlassClarity": "Kebeningan kaca", + "appearanceGlassClaritySubtitle": "Atur transparansi menu dan navigasi Mornye. Kontras tulisan tetap dijaga.", + "appearanceGlassTinted": "Pekat", + "appearanceGlassClear": "Bening", "appearanceUseSystemFont": "Gunakan font sistem", "appearanceUseSystemFontSubtitle": "Gunakan jenis huruf bawaan perangkat untuk teks aplikasi.", "@appearanceStyle": { diff --git a/lib/models/theme_settings.dart b/lib/models/theme_settings.dart index d04c9e3d..a7de8517 100644 --- a/lib/models/theme_settings.dart +++ b/lib/models/theme_settings.dart @@ -7,6 +7,13 @@ const String kUseAmoledKey = 'use_amoled'; const String kThemeStyleKey = 'theme_style'; const String kMornyeAccentKey = 'mornye_accent'; const String kUseSystemFontKey = 'use_system_font'; +const String kMornyeGlassClarityKey = 'mornye_glass_clarity'; +const double kDefaultMornyeGlassClarity = 0.75; + +double normalizeMornyeGlassClarity(num? value) => + value != null && value.isFinite + ? value.toDouble().clamp(0.0, 1.0) + : kDefaultMornyeGlassClarity; enum AppThemeStyle { material, mornye } @@ -35,6 +42,7 @@ class ThemeSettings { final AppThemeStyle style; final MornyeAccent mornyeAccent; final bool useSystemFont; + final double mornyeGlassClarity; const ThemeSettings({ this.themeMode = ThemeMode.system, @@ -44,6 +52,7 @@ class ThemeSettings { this.style = AppThemeStyle.material, this.mornyeAccent = MornyeAccent.red, this.useSystemFont = false, + this.mornyeGlassClarity = kDefaultMornyeGlassClarity, }); Color get seedColor => Color(seedColorValue); @@ -56,6 +65,7 @@ class ThemeSettings { AppThemeStyle? style, MornyeAccent? mornyeAccent, bool? useSystemFont, + double? mornyeGlassClarity, }) { return ThemeSettings( themeMode: themeMode ?? this.themeMode, @@ -65,6 +75,9 @@ class ThemeSettings { style: style ?? this.style, mornyeAccent: mornyeAccent ?? this.mornyeAccent, useSystemFont: useSystemFont ?? this.useSystemFont, + mornyeGlassClarity: normalizeMornyeGlassClarity( + mornyeGlassClarity ?? this.mornyeGlassClarity, + ), ); } @@ -76,6 +89,7 @@ class ThemeSettings { kThemeStyleKey: style.name, kMornyeAccentKey: mornyeAccent.name, kUseSystemFontKey: useSystemFont, + kMornyeGlassClarityKey: mornyeGlassClarity, }; factory ThemeSettings.fromJson(Map json) { @@ -87,6 +101,9 @@ class ThemeSettings { style: themeStyleFromString(json[kThemeStyleKey] as String?), mornyeAccent: mornyeAccentFromString(json[kMornyeAccentKey] as String?), useSystemFont: json[kUseSystemFontKey] as bool? ?? false, + mornyeGlassClarity: normalizeMornyeGlassClarity( + json[kMornyeGlassClarityKey] as num?, + ), ); } @@ -100,7 +117,8 @@ class ThemeSettings { other.useAmoled == useAmoled && other.style == style && other.mornyeAccent == mornyeAccent && - other.useSystemFont == useSystemFont; + other.useSystemFont == useSystemFont && + other.mornyeGlassClarity == mornyeGlassClarity; } @override @@ -111,7 +129,8 @@ class ThemeSettings { useAmoled.hashCode ^ style.hashCode ^ mornyeAccent.hashCode ^ - useSystemFont.hashCode; + useSystemFont.hashCode ^ + mornyeGlassClarity.hashCode; } ThemeMode themeModeFromString(String? value) { diff --git a/lib/providers/theme_provider.dart b/lib/providers/theme_provider.dart index a22526fb..b7280ab8 100644 --- a/lib/providers/theme_provider.dart +++ b/lib/providers/theme_provider.dart @@ -16,6 +16,9 @@ ThemeSettings loadBootstrapThemeSettings(SharedPreferences prefs) { style: themeStyleFromString(prefs.getString(kThemeStyleKey)), mornyeAccent: mornyeAccentFromString(prefs.getString(kMornyeAccentKey)), useSystemFont: prefs.getBool(kUseSystemFontKey) ?? false, + mornyeGlassClarity: normalizeMornyeGlassClarity( + prefs.getDouble(kMornyeGlassClarityKey), + ), ); } @@ -51,6 +54,7 @@ class ThemeNotifier extends Notifier { await prefs.setString(kThemeStyleKey, state.style.name); await prefs.setString(kMornyeAccentKey, state.mornyeAccent.name); await prefs.setBool(kUseSystemFontKey, state.useSystemFont); + await prefs.setDouble(kMornyeGlassClarityKey, state.mornyeGlassClarity); } catch (e) { debugPrint('Error saving theme settings: $e'); } @@ -90,4 +94,12 @@ class ThemeNotifier extends Notifier { state = state.copyWith(useSystemFont: value); await _saveToStorage(); } + + Future setMornyeGlassClarity( + double value, { + bool persist = true, + }) async { + state = state.copyWith(mornyeGlassClarity: value); + if (persist) await _saveToStorage(); + } } diff --git a/lib/screens/settings/appearance_settings_page.dart b/lib/screens/settings/appearance_settings_page.dart index 00f22afe..c997966a 100644 --- a/lib/screens/settings/appearance_settings_page.dart +++ b/lib/screens/settings/appearance_settings_page.dart @@ -111,6 +111,7 @@ class AppearanceSettingsPage extends ConsumerWidget { .read(themeProvider.notifier) .setMornyeAccent, ), + const _MornyeGlassClaritySlider(), ], ), ), @@ -481,6 +482,98 @@ class _ThemePreviewCard extends StatelessWidget { } } +class _MornyeGlassClaritySlider extends ConsumerWidget { + const _MornyeGlassClaritySlider(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final clarity = ref.watch( + themeProvider.select((settings) => settings.mornyeGlassClarity), + ); + final notifier = ref.read(themeProvider.notifier); + final theme = Theme.of(context); + final l10n = context.l10n; + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.appearanceGlassClarity, style: theme.textTheme.titleMedium), + const SizedBox(height: 4), + Text( + l10n.appearanceGlassClaritySubtitle, + style: theme.textTheme.bodySmall, + ), + const SizedBox(height: 16), + ExcludeSemantics( + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + theme.colorScheme.primary, + const Color(0xff277f94), + const Color(0xffc69756), + ], + ), + ), + child: Padding( + padding: const EdgeInsets.all(18), + child: MornyeGlassPanel( + radius: 22, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 18, + vertical: 14, + ), + child: Row( + children: [ + const Icon(Icons.music_note_rounded), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Mornye', + style: theme.textTheme.titleMedium, + ), + ), + const Icon(Icons.play_arrow_rounded), + ], + ), + ), + ), + ), + ), + ), + ), + Semantics( + label: l10n.appearanceGlassClarity, + child: Slider( + key: const ValueKey('mornye-glass-clarity'), + value: clarity, + divisions: 20, + label: '${(clarity * 100).round()}%', + onChanged: (value) => + notifier.setMornyeGlassClarity(value, persist: false), + onChangeEnd: notifier.setMornyeGlassClarity, + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + l10n.appearanceGlassTinted, + style: theme.textTheme.bodySmall, + ), + Text(l10n.appearanceGlassClear, style: theme.textTheme.bodySmall), + ], + ), + ], + ), + ); + } +} + class _MornyeAccentPicker extends StatelessWidget { const _MornyeAccentPicker({required this.selected, required this.onChanged}); diff --git a/lib/screens/upgrade_intro_screen.dart b/lib/screens/upgrade_intro_screen.dart index 8bc9c7fb..95634860 100644 --- a/lib/screens/upgrade_intro_screen.dart +++ b/lib/screens/upgrade_intro_screen.dart @@ -329,6 +329,7 @@ class _ThemePicker extends ConsumerWidget { brightness, accent: settings.mornyeAccent, useSystemFont: settings.useSystemFont, + glassClarity: settings.mornyeGlassClarity, ) : brightness == Brightness.dark ? AppTheme.dark( diff --git a/lib/theme/dynamic_color_wrapper.dart b/lib/theme/dynamic_color_wrapper.dart index dd62c593..21eeed02 100644 --- a/lib/theme/dynamic_color_wrapper.dart +++ b/lib/theme/dynamic_color_wrapper.dart @@ -23,11 +23,13 @@ class DynamicColorWrapper extends ConsumerWidget { Brightness.light, accent: themeSettings.mornyeAccent, useSystemFont: themeSettings.useSystemFont, + glassClarity: themeSettings.mornyeGlassClarity, ), MornyeTheme.build( Brightness.dark, accent: themeSettings.mornyeAccent, useSystemFont: themeSettings.useSystemFont, + glassClarity: themeSettings.mornyeGlassClarity, ), themeSettings.themeMode, ); diff --git a/lib/theme/mornye_theme.dart b/lib/theme/mornye_theme.dart index 8782a7ae..f7a48dcc 100644 --- a/lib/theme/mornye_theme.dart +++ b/lib/theme/mornye_theme.dart @@ -13,11 +13,17 @@ class MornyeTheme extends ThemeExtension { this.chromeSurface, this.accent = MornyeAccent.red, this.useSystemFont = false, + this.glassClarity = kDefaultMornyeGlassClarity, }); final Color? chromeSurface; final MornyeAccent accent; final bool useSystemFont; + final double glassClarity; + + static double glassClarityOf(BuildContext context) => + Theme.of(context).extension()?.glassClarity ?? + kDefaultMornyeGlassClarity; /// A single translucent fill for controls inside an existing glass surface. static Color controlFill(BuildContext context, {bool enabled = true}) { @@ -111,6 +117,7 @@ class MornyeTheme extends ThemeExtension { Color? chromeSurface, MornyeAccent accent = MornyeAccent.red, bool useSystemFont = false, + double glassClarity = kDefaultMornyeGlassClarity, }) { final key = ( defaultTargetPlatform, @@ -122,7 +129,7 @@ class MornyeTheme extends ThemeExtension { final cached = _themeCache.remove(key); if (cached != null) { _themeCache[key] = cached; - return cached; + return _withGlassClarity(cached, glassClarity); } final theme = _build( brightness, @@ -134,7 +141,23 @@ class MornyeTheme extends ThemeExtension { _themeCache.remove(_themeCache.keys.first); } _themeCache[key] = theme; - return theme; + return _withGlassClarity(theme, glassClarity); + } + + // Moving the clarity slider only changes the material, not typography or + // seeded colors. Keep the expensive base theme cached independently. + static ThemeData _withGlassClarity(ThemeData theme, double value) { + final clarity = normalizeMornyeGlassClarity(value); + if (theme.extension()!.glassClarity == clarity) return theme; + return theme.copyWith( + extensions: [ + for (final extension in theme.extensions.values) + if (extension is MornyeTheme) + extension.copyWith(glassClarity: clarity) + else + extension, + ], + ); } /// Rebuild local surfaces without losing the user's Mornye preferences. @@ -150,6 +173,7 @@ class MornyeTheme extends ThemeExtension { chromeSurface: chromeSurface, accent: preferences?.accent ?? MornyeAccent.red, useSystemFont: preferences?.useSystemFont ?? false, + glassClarity: preferences?.glassClarity ?? kDefaultMornyeGlassClarity, ); } @@ -366,10 +390,12 @@ class MornyeTheme extends ThemeExtension { Color? chromeSurface, MornyeAccent? accent, bool? useSystemFont, + double? glassClarity, }) => MornyeTheme( chromeSurface: chromeSurface ?? this.chromeSurface, accent: accent ?? this.accent, useSystemFont: useSystemFont ?? this.useSystemFont, + glassClarity: glassClarity ?? this.glassClarity, ); @override @@ -379,6 +405,9 @@ class MornyeTheme extends ThemeExtension { useSystemFont: t < 0.5 ? useSystemFont : other?.useSystemFont ?? useSystemFont, + glassClarity: + glassClarity + + ((other?.glassClarity ?? glassClarity) - glassClarity) * t, ); } diff --git a/lib/widgets/mornye_chrome.dart b/lib/widgets/mornye_chrome.dart index 8c493667..9b967d0d 100644 --- a/lib/widgets/mornye_chrome.dart +++ b/lib/widgets/mornye_chrome.dart @@ -237,12 +237,7 @@ class MornyeGlassPanel extends ConsumerWidget { tintColor: tintColor, backdropFilter: backdropFilter, blurEnabled: blur, - child: Material( - color: strongTint - ? Theme.of(context).colorScheme.surface.withValues(alpha: 0.60) - : Colors.transparent, - child: child, - ), + child: Material(color: Colors.transparent, child: child), ); } } @@ -285,15 +280,19 @@ class MornyeGlass extends StatelessWidget { /// Keeps floating controls readable over arbitrary album artwork. final bool strongTint; - /// Uses a single tint instead of layered highlights for translucent surfaces. + /// Base tint before applying the user's glass clarity preference. final double? tintOpacity; final Color? tintColor; final ImageFilter? backdropFilter; @override Widget build(BuildContext context) { + final clarity = MornyeTheme.glassClarityOf(context); final useGlass = - _useLens && blurEnabled && !MediaQuery.highContrastOf(context); + _useLens && + blurEnabled && + clarity > 0 && + !MediaQuery.highContrastOf(context); Widget lens(Widget child) => Consumer( // Mid-range Android keeps the frosted surface but not the shader lens. builder: (context, ref, child) => @@ -314,39 +313,33 @@ class MornyeGlass extends StatelessWidget { } Widget _liquidLens(Widget child) => Builder( - builder: (context) { - final scheme = Theme.of(context).colorScheme; - final dark = scheme.brightness == Brightness.dark; - return NativeGlassMetrics( - child: LiquidGlassLens( - style: LiquidGlassStyle( - shape: LiquidGlassShape.continuousRoundedRectangle( - cornerRadius: radius, - borderWidth: dark && tintOpacity == null ? 0 : 0.6, - lightIntensity: dark && tintOpacity == null ? 0 : 0.18, - ), - appearance: LiquidGlassAppearance( - color: tintOpacity != null - ? Colors.transparent - : dark - ? scheme.surfaceContainerHigh.withValues(alpha: 0.28) - : Colors.white.withValues(alpha: strongTint ? 0.20 : 0.55), - // The surface already blurs the backdrop. Refracting that - // frosted result needs no second Gaussian blur pass. - blur: const LiquidGlassBlur(), - ), - refraction: const LiquidGlassRefraction( - distortion: 0.02, - distortionWidth: 8, - chromaticAberration: 0, - ), + builder: (context) => NativeGlassMetrics( + child: LiquidGlassLens( + style: LiquidGlassStyle( + shape: LiquidGlassShape.continuousRoundedRectangle( + cornerRadius: radius, + // The shared rim owns directional highlights, including + // surfaces without a lens. Avoid a second specular border. + borderWidth: 0, + lightIntensity: 0, + ), + appearance: const LiquidGlassAppearance( + color: Colors.transparent, + // The surface already blurs the backdrop. Refracting that + // frosted result needs no second Gaussian blur pass. + blur: LiquidGlassBlur(), + ), + refraction: const LiquidGlassRefraction( + distortion: 0.02, + distortionWidth: 8, + chromaticAberration: 0, ), - // Only the shader uses window metrics; responsive content and - // decoded artwork retain the surrounding tablet layout scale. - child: MediaQuery(data: MediaQuery.of(context), child: child), ), - ); - }, + // Only the shader uses window metrics; responsive content and + // decoded artwork retain the surrounding tablet layout scale. + child: MediaQuery(data: MediaQuery.of(context), child: child), + ), + ), ); } @@ -354,6 +347,34 @@ class MornyeGlass extends StatelessWidget { /// lens adds refraction above this frosted base, never above bare page text. class _MornyeGlassSurface extends StatelessWidget { static final _backdropBlur = ImageFilter.blur(sigmaX: 18, sigmaY: 18); + // Reduce luminance without flattening the backdrop's color differences. + // Subtract 35% of Rec.709 luma from every channel: white is bounded at 0.65, + // while colored artwork stays visible without per-frame pixel readback. + static final _darkBackdrop = ImageFilter.compose( + outer: const ColorFilter.matrix([ + 0.92559, + -0.25032, + -0.02527, + 0, + 0, + -0.07441, + 0.74968, + -0.02527, + 0, + 0, + -0.07441, + -0.25032, + 0.97473, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + inner: _backdropBlur, + ); const _MornyeGlassSurface({ required this.child, @@ -380,52 +401,27 @@ class _MornyeGlassSurface extends StatelessWidget { @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; - final useBlur = blurEnabled && !MediaQuery.highContrastOf(context); + final clarity = MornyeTheme.glassClarityOf(context); + final useBlur = + blurEnabled && clarity > 0 && !MediaQuery.highContrastOf(context); final dark = scheme.brightness == Brightness.dark; final shape = BorderRadius.vertical( top: firstInGroup ? Radius.circular(radius) : Radius.zero, bottom: lastInGroup ? Radius.circular(radius) : Radius.zero, ); - final rim = BorderSide( - color: dark - ? Colors.white.withValues(alpha: tintColor == null ? 0.16 : 0.28) - : Colors.black.withValues(alpha: 0.17), - width: 0.75, - ); - final border = Border( - top: firstInGroup ? rim : BorderSide.none, - bottom: lastInGroup ? rim : BorderSide.none, - left: rim, - right: rim, - ); // A translucent white tint must not become solid white behind light // text when accessibility or the device profile disables blur. + final baseTint = (tintColor ?? scheme.surfaceContainerHigh).withValues( + alpha: (tintOpacity ?? (strongTint ? 0.80 : 0.60)) * (dark ? 0.82 : 0.88), + ); final tint = useBlur - ? tintColor ?? scheme.surfaceContainerHigh + ? Color.alphaBlend( + scheme.surfaceContainerHigh.withValues(alpha: 1 - clarity), + baseTint, + ) : scheme.surfaceContainerHigh; final surface = DecoratedBox( - decoration: BoxDecoration( - color: tint.withValues( - alpha: useBlur ? (tintOpacity ?? (strongTint ? 0.80 : 0.60)) : 1, - ), - gradient: useBlur && !dark && tintOpacity == null - ? LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - stops: const [0, 0.35, 0.75, 1], - colors: [ - Colors.white.withValues(alpha: 0.90), - Colors.white.withValues(alpha: strongTint ? 0.80 : 0.78), - scheme.surfaceContainerHigh.withValues( - alpha: strongTint ? 0.76 : 0.72, - ), - Colors.white.withValues(alpha: 0.85), - ], - ) - : null, - borderRadius: shape, - border: dark ? border : null, - ), + decoration: BoxDecoration(color: tint, borderRadius: shape), child: child, ); return DecoratedBox( @@ -434,31 +430,29 @@ class _MornyeGlassSurface extends StatelessWidget { boxShadow: firstInGroup && lastInGroup ? [ BoxShadow( - color: Colors.black.withValues(alpha: dark ? 0.2 : 0.06), - blurRadius: dark ? 18 : 10, + color: Colors.black.withValues(alpha: dark ? 0.14 : 0.08), + blurRadius: 12, // Clear glass keeps its tint; the shadow belongs outside // the panel, not underneath its translucent center. - blurStyle: tintColor == null - ? BlurStyle.normal - : BlurStyle.outer, - offset: Offset(0, dark ? 4 : 2), + blurStyle: BlurStyle.outer, + offset: const Offset(0, 3), ), ] : null, ), - child: DecoratedBox( - // Paint the light outline above the lens so its pale tint cannot wash - // the edge out on an all-white page. Dark chrome keeps its quiet rim. - position: DecorationPosition.foreground, - decoration: BoxDecoration( - borderRadius: shape, - border: dark ? null : border, + child: CustomPaint( + foregroundPainter: _MornyeGlassRim( + shape: shape, + firstInGroup: firstInGroup, + lastInGroup: lastInGroup, + illuminated: useBlur, ), child: ClipRRect( borderRadius: shape, child: useBlur ? BackdropFilter( - filter: backdropFilter ?? _backdropBlur, + filter: + backdropFilter ?? (dark ? _darkBackdrop : _backdropBlur), child: surface, ) : surface, @@ -468,6 +462,73 @@ class _MornyeGlassSurface extends StatelessWidget { } } +/// Static, axis-aligned highlights: bright upper/lower inner edges and a thin +/// shadow at the sides. No offscreen layer, extra blur, or per-frame readback. +class _MornyeGlassRim extends CustomPainter { + const _MornyeGlassRim({ + required this.shape, + required this.firstInGroup, + required this.lastInGroup, + required this.illuminated, + }); + + final BorderRadius shape; + final bool firstInGroup; + final bool lastInGroup; + final bool illuminated; + + @override + void paint(Canvas canvas, Size size) { + if (size.isEmpty) return; + final bounds = Offset.zero & size; + final outline = shape.toRRect( + Rect.fromLTRB( + 0, + firstInGroup ? 0 : -2, + size.width, + lastInGroup ? size.height : size.height + 2, + ), + ); + canvas.save(); + canvas.clipRect(bounds); + canvas.drawRRect( + outline.deflate(0.25), + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 0.5 + ..color = Colors.black.withValues(alpha: 0.22), + ); + if (illuminated) { + canvas.drawRRect( + outline.deflate(0.9), + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 0.8 + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: const [0, 0.18, 0.5, 0.82, 1], + colors: [ + firstInGroup ? const Color(0x99ffffff) : Colors.transparent, + const Color(0x18ffffff), + const Color(0x30000000), + const Color(0x18ffffff), + lastInGroup ? const Color(0x80ffffff) : Colors.transparent, + ], + ).createShader(bounds), + ); + } + canvas.restore(); + } + + @override + bool shouldRepaint(covariant _MornyeGlassRim oldDelegate) => + shape != oldDelegate.shape || + firstInGroup != oldDelegate.firstInGroup || + lastInGroup != oldDelegate.lastInGroup || + illuminated != oldDelegate.illuminated; +} + /// A searchable category uses the same material as the navigation capsule. class MornyeFilterChip extends ConsumerWidget { const MornyeFilterChip({ diff --git a/test/appearance_settings_test.dart b/test/appearance_settings_test.dart index 4f6e7b3c..b0a997db 100644 --- a/test/appearance_settings_test.dart +++ b/test/appearance_settings_test.dart @@ -10,6 +10,23 @@ import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; void main() { + test('glass clarity restores safely from old and invalid settings', () { + expect( + ThemeSettings.fromJson({}).mornyeGlassClarity, + kDefaultMornyeGlassClarity, + ); + for (final (saved, expected) in [ + (0, 0.0), + (1, 1.0), + (-1, 0.0), + (2, 1.0), + (double.nan, kDefaultMornyeGlassClarity), + ]) { + final settings = ThemeSettings.fromJson({kMornyeGlassClarityKey: saved}); + expect(settings.mornyeGlassClarity, expected); + expect(ThemeSettings.fromJson(settings.toJson()), settings); + } + }); test('custom accents keep text readable in both appearances', () { double contrast(Color first, Color second) { final a = first.computeLuminance(); @@ -69,6 +86,43 @@ void main() { } for (final mode in [ThemeMode.light, ThemeMode.dark]) { + testWidgets('glass clarity previews live and survives restart ($mode)', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({ + kThemeStyleKey: 'mornye', + kThemeModeKey: mode.name, + }); + final prefs = await SharedPreferences.getInstance(); + await openSettings(tester, prefs); + final slider = find.byKey(const ValueKey('mornye-glass-clarity')); + await tester.ensureVisible(slider); + await tester.pumpAndSettle(); + await tester.drag(slider, const Offset(160, 0)); + await tester.pumpAndSettle(); + expect(tester.widget(slider).value, 1); + expect(prefs.getDouble(kMornyeGlassClarityKey), 1); + final context = tester.element(slider); + expect(MornyeTheme.glassClarityOf(context), 1); + expect( + MornyeTheme.fromContext( + context, + brightness: Brightness.dark, + ).extension()!.glassClarity, + 1, + ); + await tester.pumpWidget(const SizedBox()); + await openSettings(tester, prefs); + expect(tester.widget(slider).value, 1); + await tester.ensureVisible(slider); + await tester.pumpAndSettle(); + await tester.drag(slider, const Offset(-320, 0)); + await tester.pumpAndSettle(); + expect(tester.widget(slider).value, 0); + expect(prefs.getDouble(kMornyeGlassClarityKey), 0); + expect(tester.takeException(), isNull); + }); + testWidgets('Mornye accent updates live and survives restart ($mode)', ( tester, ) async { @@ -140,7 +194,11 @@ void main() { tester.element(page), ).textTheme.bodyLarge!.fontFamily; final setting = find.text('Use system font'); - await tester.ensureVisible(setting); + await tester.scrollUntilVisible( + setting, + 200, + scrollable: find.byType(Scrollable).first, + ); await tester.pumpAndSettle(); await tester.tap(setting); await tester.pumpAndSettle(); @@ -164,7 +222,11 @@ void main() { Theme.of(tester.element(page)).textTheme.bodyLarge!.fontFamily, systemFamily, ); - await tester.ensureVisible(setting); + await tester.scrollUntilVisible( + setting, + 200, + scrollable: find.byType(Scrollable).first, + ); await tester.pumpAndSettle(); await tester.tap(setting); await tester.pumpAndSettle(); diff --git a/test/mornye_bottom_bar_test.dart b/test/mornye_bottom_bar_test.dart index dbbe8cbc..d55ef099 100644 --- a/test/mornye_bottom_bar_test.dart +++ b/test/mornye_bottom_bar_test.dart @@ -43,6 +43,7 @@ void main() { Color? backdrop, Color? chromeSurface, Widget? body, + double glassClarity = 0.75, ValueNotifier? activeTab, }) async { tester.view.physicalSize = const Size(393, 760); @@ -68,7 +69,11 @@ void main() { backdropBlurEnabledProvider.overrideWithValue(blur), ], child: MaterialApp( - theme: MornyeTheme.build(brightness, chromeSurface: chromeSurface), + theme: MornyeTheme.build( + brightness, + chromeSurface: chromeSurface, + glassClarity: glassClarity, + ), builder: (_, child) => RepaintBoundary(key: capture, child: child), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, @@ -389,85 +394,88 @@ void main() { } for (final brightness in Brightness.values) { - for (final backdrop in [ - Colors.white, - Colors.black, - if (brightness == Brightness.dark) albumBlue, - ]) { - testWidgets( - 'floating glass stays legible in $brightness over $backdrop', - (tester) async { - await pumpShell( - tester, - blur: true, - brightness: brightness, - backdrop: backdrop, - chromeSurface: backdrop == albumBlue ? albumBlue : null, - ); - final artist = find.text('Artist'); - // The glass renderer also builds a copy for the refractive pill. - final libraryIcon = find - .descendant( - of: find.byType(MornyeTabBar), - matching: find.byIcon(Icons.music_note), - ) - .first; - final artistRect = tester.getRect(artist); - final iconRect = tester.getRect(libraryIcon); - final foregrounds = [ - tester.widget(artist).style!.color!, - IconTheme.of(tester.element(libraryIcon)).color!, - ]; - final samples = [ - Offset(artistRect.left + 8, artistRect.bottom + 2), - Offset(iconRect.right + 8, iconRect.center.dy), - ]; - final backgrounds = await tester.runAsync(() async { - final boundary = tester.renderObject( - find.byKey(capture), + for (final clarity in [0.0, 0.75, 1.0]) { + for (final backdrop in [ + Colors.white, + Colors.black, + if (brightness == Brightness.dark) albumBlue, + ]) { + testWidgets( + 'floating glass stays legible in $brightness over $backdrop (clarity: $clarity)', + (tester) async { + await pumpShell( + tester, + blur: true, + brightness: brightness, + glassClarity: clarity, + backdrop: backdrop, + chromeSurface: backdrop == albumBlue ? albumBlue : null, ); - final image = await boundary.toImage(); - final bytes = (await image.toByteData( - format: ui.ImageByteFormat.rawRgba, - ))!; - final colors = []; - for (final point in samples) { - final offset = - (point.dy.floor() * image.width + point.dx.floor()) * 4; - colors.add( - Color.fromARGB( - bytes.getUint8(offset + 3), - bytes.getUint8(offset), - bytes.getUint8(offset + 1), - bytes.getUint8(offset + 2), - ), + final artist = find.text('Artist'); + // The glass renderer also builds a copy for the refractive pill. + final libraryIcon = find + .descendant( + of: find.byType(MornyeTabBar), + matching: find.byIcon(Icons.music_note), + ) + .first; + final artistRect = tester.getRect(artist); + final iconRect = tester.getRect(libraryIcon); + final foregrounds = [ + tester.widget(artist).style!.color!, + IconTheme.of(tester.element(libraryIcon)).color!, + ]; + final samples = [ + Offset(artistRect.left + 8, artistRect.bottom + 2), + Offset(iconRect.right + 8, iconRect.center.dy), + ]; + final backgrounds = await tester.runAsync(() async { + final boundary = tester.renderObject( + find.byKey(capture), + ); + final image = await boundary.toImage(); + final bytes = (await image.toByteData( + format: ui.ImageByteFormat.rawRgba, + ))!; + final colors = []; + for (final point in samples) { + final offset = + (point.dy.floor() * image.width + point.dx.floor()) * 4; + colors.add( + Color.fromARGB( + bytes.getUint8(offset + 3), + bytes.getUint8(offset), + bytes.getUint8(offset + 1), + bytes.getUint8(offset + 2), + ), + ); + } + image.dispose(); + return colors; + }); + for (var index = 0; index < foregrounds.length; index++) { + if (backdrop == albumBlue) { + final glass = HSLColor.fromColor(backgrounds![index]); + final page = HSLColor.fromColor(albumBlue); + expect(glass.hue, closeTo(page.hue, 8)); + expect(glass.saturation, greaterThan(page.saturation * 0.7)); + } + final luminances = [ + foregrounds[index].computeLuminance(), + backgrounds![index].computeLuminance(), + ]..sort(); + final contrast = + (luminances.last + 0.05) / (luminances.first + 0.05); + expect( + contrast, + greaterThanOrEqualTo(index == 0 ? 4.5 : 3), + reason: index == 0 ? 'Mini-player artist' : 'Inactive tab icon', ); } - image.dispose(); - return colors; - }); - for (var index = 0; index < foregrounds.length; index++) { - if (backdrop == albumBlue) { - final glass = HSLColor.fromColor(backgrounds![index]); - final page = HSLColor.fromColor(albumBlue); - expect(glass.hue, closeTo(page.hue, 8)); - expect(glass.saturation, greaterThan(page.saturation * 0.7)); - } - final luminances = [ - foregrounds[index].computeLuminance(), - backgrounds![index].computeLuminance(), - ]..sort(); - final contrast = - (luminances.last + 0.05) / (luminances.first + 0.05); - expect( - contrast, - greaterThanOrEqualTo(index == 0 ? 4.5 : 3), - reason: index == 0 ? 'Mini-player artist' : 'Inactive tab icon', - ); - } - expect(tester.takeException(), isNull); - }, - ); + expect(tester.takeException(), isNull); + }, + ); + } } } diff --git a/test/mornye_context_menu_test.dart b/test/mornye_context_menu_test.dart index 238481e7..e856b79f 100644 --- a/test/mornye_context_menu_test.dart +++ b/test/mornye_context_menu_test.dart @@ -6,6 +6,7 @@ import 'package:flutter/rendering.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotiflac_android/models/theme_settings.dart'; import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/widgets/mornye_context_menu.dart'; @@ -26,6 +27,7 @@ void main() { bool lowEnd = false, Color? backgroundColor, GlobalKey? capture, + double glassClarity = kDefaultMornyeGlassClarity, }) async { tester.view.physicalSize = size; tester.view.devicePixelRatio = 1; @@ -37,7 +39,7 @@ void main() { backdropBlurEnabledProvider.overrideWithValue(false), ], child: MaterialApp( - theme: MornyeTheme.build(brightness), + theme: MornyeTheme.build(brightness, glassClarity: glassClarity), builder: (context, child) => RepaintBoundary( key: capture, child: MediaQuery( @@ -137,59 +139,63 @@ void main() { }))!; for (final brightness in Brightness.values) { - for (final background in [ - Colors.white, - Colors.black, - const Color(0xff6c3a22), - ]) { - testWidgets('menu labels stay readable over $background ($brightness)', ( - tester, - ) async { - final originalDisableShadows = debugDisableShadows; - debugDisableShadows = false; - try { - final capture = GlobalKey(); - await openMenu( - tester, - anchor: const Rect.fromLTWH(330, 144, 44, 44), - brightness: brightness, - backgroundColor: background, - capture: capture, - onResult: (_) {}, - ); - final menu = tester.getRect(find.byType(MornyeContextMenu)); - final pixels = await samplePixels(tester, capture, [ - for (final x in [menu.left + 8, menu.right - 8]) - for (final fraction in [0.25, 0.5, 0.75]) - Offset(x, menu.top + menu.height * fraction), - ]); - final foreground = MornyeTheme.build( - brightness, - ).colorScheme.onSurface; - for (final inside in pixels) { - // Busy lyrics and bright artwork must not wash out either label. - for (final textColor in [ - foreground, - Color.alphaBlend(foreground.withValues(alpha: 0.96), inside), - ]) { - final luminances = [ - inside.computeLuminance(), - textColor.computeLuminance(), - ]..sort(); - final contrast = - (luminances.last + 0.05) / (luminances.first + 0.05); - expect(contrast, greaterThanOrEqualTo(4.5)); + for (final clarity in [0.0, kDefaultMornyeGlassClarity, 1.0]) { + for (final background in [ + Colors.white, + Colors.black, + const Color(0xff6c3a22), + ]) { + testWidgets( + 'menu labels stay readable over $background ($brightness, clarity: $clarity)', + (tester) async { + final originalDisableShadows = debugDisableShadows; + debugDisableShadows = false; + try { + final capture = GlobalKey(); + await openMenu( + tester, + anchor: const Rect.fromLTWH(330, 144, 44, 44), + brightness: brightness, + glassClarity: clarity, + backgroundColor: background, + capture: capture, + onResult: (_) {}, + ); + final menu = tester.getRect(find.byType(MornyeContextMenu)); + final pixels = await samplePixels(tester, capture, [ + for (final x in [menu.left + 8, menu.right - 8]) + for (final fraction in [0.25, 0.5, 0.75]) + Offset(x, menu.top + menu.height * fraction), + ]); + final foreground = MornyeTheme.build( + brightness, + ).colorScheme.onSurface; + for (final inside in pixels) { + // Busy lyrics and bright artwork must not wash out either label. + for (final textColor in [ + foreground, + Color.alphaBlend(foreground.withValues(alpha: 0.96), inside), + ]) { + final luminances = [ + inside.computeLuminance(), + textColor.computeLuminance(), + ]..sort(); + final contrast = + (luminances.last + 0.05) / (luminances.first + 0.05); + expect(contrast, greaterThanOrEqualTo(4.5)); + } + // The panel remains even at both edges without an offset lens. + expect(inside.r, closeTo(pixels.first.r, 0.015)); + expect(inside.g, closeTo(pixels.first.g, 0.015)); + expect(inside.b, closeTo(pixels.first.b, 0.015)); + } + expect(tester.takeException(), isNull); + } finally { + debugDisableShadows = originalDisableShadows; } - // The panel remains even at both edges without an offset lens. - expect(inside.r, closeTo(pixels.first.r, 0.015)); - expect(inside.g, closeTo(pixels.first.g, 0.015)); - expect(inside.b, closeTo(pixels.first.b, 0.015)); - } - expect(tester.takeException(), isNull); - } finally { - debugDisableShadows = originalDisableShadows; - } - }); + }, + ); + } } testWidgets('menu stays on screen near a bottom edge ($brightness)', (