From a2974acee0fbcc57fdbb1a6455c0091832e4cf52 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:42:15 +0700 Subject: [PATCH] fix(mornye): respect glass clarity and accessibility limits --- lib/l10n/arb/app_en.arb | 13 +++ lib/l10n/arb/app_id.arb | 2 + .../settings/appearance_settings_page.dart | 27 +++++- lib/widgets/mornye_bottom_bar.dart | 10 +-- lib/widgets/mornye_chrome.dart | 28 +++++-- test/appearance_settings_test.dart | 84 ++++++++++++++++++- test/mornye_glass_level_test.dart | 69 +++++++++++++++ 7 files changed, 218 insertions(+), 15 deletions(-) diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 09577b64..8257c8b1 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -74,6 +74,19 @@ "appearanceGlassClaritySubtitle": "Adjust transparency in Mornye menus and navigation. Text contrast is preserved.", "appearanceGlassTinted": "Tinted", "appearanceGlassClear": "Clear", + "appearanceGlassClarityBlurOff": "Glass is opaque on this device. Turn on “{setting}” to adjust clarity.", + "@appearanceGlassClarityBlurOff": { + "description": "Shown under the disabled glass clarity slider when the device renders Mornye glass without blur. The placeholder is the name of the force-blur toggle.", + "placeholders": { + "setting": { + "type": "String" + } + } + }, + "appearanceGlassClarityHighContrast": "Glass stays opaque while the system’s high contrast setting is on.", + "@appearanceGlassClarityHighContrast": { + "description": "Shown under the disabled glass clarity slider while the OS high contrast accessibility setting is on" + }, "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 7507882f..5e11b515 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -74,6 +74,8 @@ "appearanceGlassClaritySubtitle": "Atur transparansi menu dan navigasi Mornye. Kontras tulisan tetap dijaga.", "appearanceGlassTinted": "Pekat", "appearanceGlassClear": "Bening", + "appearanceGlassClarityBlurOff": "Kaca dibuat pekat di perangkat ini. Aktifkan “{setting}” untuk mengatur kebeningan.", + "appearanceGlassClarityHighContrast": "Kaca tetap pekat selama pengaturan kontras tinggi sistem aktif.", "appearanceUseSystemFont": "Gunakan font sistem", "appearanceUseSystemFontSubtitle": "Gunakan jenis huruf bawaan perangkat untuk teks aplikasi.", "@appearanceStyle": { diff --git a/lib/screens/settings/appearance_settings_page.dart b/lib/screens/settings/appearance_settings_page.dart index c997966a..dd3b821a 100644 --- a/lib/screens/settings/appearance_settings_page.dart +++ b/lib/screens/settings/appearance_settings_page.dart @@ -3,6 +3,7 @@ import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/constants/language_choices.dart'; +import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/theme_provider.dart'; import 'package:spotiflac_android/models/theme_settings.dart'; @@ -493,6 +494,10 @@ class _MornyeGlassClaritySlider extends ConsumerWidget { final notifier = ref.read(themeProvider.notifier); final theme = Theme.of(context); final l10n = context.l10n; + // Flat devices and high contrast draw every glass surface opaque, so the + // slider would change nothing visible. Keep the stored value untouched. + final highContrast = MediaQuery.highContrastOf(context); + final adjustable = !highContrast && ref.watch(mornyeBlurEnabledProvider); return Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), child: Column( @@ -504,6 +509,20 @@ class _MornyeGlassClaritySlider extends ConsumerWidget { l10n.appearanceGlassClaritySubtitle, style: theme.textTheme.bodySmall, ), + if (!adjustable) ...[ + const SizedBox(height: 8), + Text( + highContrast + ? l10n.appearanceGlassClarityHighContrast + : l10n.appearanceGlassClarityBlurOff( + l10n.appearanceForceBlur, + ), + key: const ValueKey('mornye-glass-clarity-unavailable'), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], const SizedBox(height: 16), ExcludeSemantics( child: ClipRRect( @@ -553,9 +572,11 @@ class _MornyeGlassClaritySlider extends ConsumerWidget { value: clarity, divisions: 20, label: '${(clarity * 100).round()}%', - onChanged: (value) => - notifier.setMornyeGlassClarity(value, persist: false), - onChangeEnd: notifier.setMornyeGlassClarity, + onChanged: adjustable + ? (value) => + notifier.setMornyeGlassClarity(value, persist: false) + : null, + onChangeEnd: adjustable ? notifier.setMornyeGlassClarity : null, ), ), Row( diff --git a/lib/widgets/mornye_bottom_bar.dart b/lib/widgets/mornye_bottom_bar.dart index 26124408..c3432d58 100644 --- a/lib/widgets/mornye_bottom_bar.dart +++ b/lib/widgets/mornye_bottom_bar.dart @@ -88,11 +88,11 @@ class MornyeBottomBar extends ConsumerWidget { ); final liquidGlass = ref.watch(mornyeLiquidGlassProvider); // Animated glass tabs already reserve 8px above their visible capsule. - final glassTabs = - blurEnabled && - liquidGlass && - !MediaQuery.disableAnimationsOf(context) && - !MediaQuery.highContrastOf(context); + final glassTabs = MornyeTabBar.usesLiquidGlass( + context, + blurEnabled: blurEnabled, + liquidGlass: liquidGlass, + ); final tabGap = glassTabs ? 0.0 : 8.0; // These contents do not depend on animation progress. Retain their widget // instances so folding only updates size/opacity wrappers each frame. diff --git a/lib/widgets/mornye_chrome.dart b/lib/widgets/mornye_chrome.dart index 9b967d0d..aa280301 100644 --- a/lib/widgets/mornye_chrome.dart +++ b/lib/widgets/mornye_chrome.dart @@ -27,8 +27,11 @@ class MornyeSegmentedControl extends ConsumerWidget { !MediaQuery.highContrastOf(context) && ref.watch(mornyeBlurEnabledProvider); final animate = !MediaQuery.disableAnimationsOf(context); + // At 0% clarity the material is opaque; drop the shader pill as + // MornyeGlass drops its lens. + final clear = MornyeTheme.glassClarityOf(context) > 0; final height = MediaQuery.textScalerOf(context).scale(15) + 36; - if (!blur || !animate || !ref.watch(mornyeLiquidGlassProvider)) { + if (!blur || !animate || !clear || !ref.watch(mornyeLiquidGlassProvider)) { // The Impeller tab bar ignores its pill mode and always runs its shader // passes. Without blur or motion, draw the resting segments directly. return _plainSegments(context, blur: blur, height: height); @@ -614,6 +617,20 @@ class MornyeTabBar extends StatelessWidget { /// Shader capsule and travelling pill; otherwise the frosted row below. final bool liquidGlass; + /// Whether the bar renders the shader capsule. Overlays aligned with its + /// icons must use the same decision. At 0% clarity the material is opaque, + /// so the shader pill is dropped as MornyeGlass drops its lens. + static bool usesLiquidGlass( + BuildContext context, { + required bool blurEnabled, + required bool liquidGlass, + }) => + blurEnabled && + liquidGlass && + MornyeTheme.glassClarityOf(context) > 0 && + !MediaQuery.disableAnimationsOf(context) && + !MediaQuery.highContrastOf(context); + final List destinations; final int selectedIndex; final ValueChanged onSelected; @@ -628,10 +645,11 @@ class MornyeTabBar extends StatelessWidget { alpha: scheme.brightness == Brightness.dark ? 0.12 : 0.08, ); final inactiveIconColor = scheme.onSurface; - if (blurEnabled && - liquidGlass && - !MediaQuery.disableAnimationsOf(context) && - !MediaQuery.highContrastOf(context)) { + if (usesLiquidGlass( + context, + blurEnabled: blurEnabled, + liquidGlass: liquidGlass, + )) { return LayoutBuilder( builder: (context, constraints) => SizedBox( // Leave room above and below for the travelling pill to lift and diff --git a/test/appearance_settings_test.dart b/test/appearance_settings_test.dart index b0a997db..4fb422e5 100644 --- a/test/appearance_settings_test.dart +++ b/test/appearance_settings_test.dart @@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/theme_settings.dart'; +import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; import 'package:spotiflac_android/providers/theme_provider.dart'; import 'package:spotiflac_android/screens/settings/appearance_settings_page.dart'; import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart'; @@ -58,8 +59,9 @@ void main() { Future openSettings( WidgetTester tester, - SharedPreferences prefs, - ) async { + SharedPreferences prefs, { + bool lowEnd = false, + }) async { tester.view.physicalSize = const Size(390, 1000); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); @@ -69,6 +71,8 @@ void main() { initialThemeSettingsProvider.overrideWithValue( loadBootstrapThemeSettings(prefs), ), + // Low-end Android without forced blur renders flat, opaque glass. + if (lowEnd) lowEndDeviceProvider.overrideWithValue(true), ], child: DynamicColorWrapper( builder: (light, dark, mode) => MaterialApp( @@ -123,6 +127,82 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('glass clarity waits for blur on flat devices ($mode)', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({ + kThemeStyleKey: 'mornye', + kThemeModeKey: mode.name, + }); + final prefs = await SharedPreferences.getInstance(); + await openSettings(tester, prefs, lowEnd: true); + final slider = find.byKey(const ValueKey('mornye-glass-clarity')); + final hint = find.byKey( + const ValueKey('mornye-glass-clarity-unavailable'), + ); + await tester.ensureVisible(slider); + await tester.pumpAndSettle(); + expect(tester.widget(slider).onChanged, isNull); + expect( + tester.widget(hint).data, + 'Glass is opaque on this device. Turn on ' + '“Always use blur effects” to adjust clarity.', + ); + await tester.drag(slider, const Offset(160, 0)); + await tester.pumpAndSettle(); + expect(tester.widget(slider).value, kDefaultMornyeGlassClarity); + expect(prefs.getDouble(kMornyeGlassClarityKey), isNull); + + final forceBlur = find.text('Always use blur effects'); + await tester.scrollUntilVisible( + forceBlur, + 200, + scrollable: find.byType(Scrollable).first, + ); + await tester.tap(forceBlur); + await tester.pumpAndSettle(); + // The lazily built slider scrolled away; bring it back. + await tester.scrollUntilVisible( + slider, + -200, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + expect(hint, findsNothing); + expect(tester.widget(slider).onChanged, isNotNull); + await tester.drag(slider, const Offset(160, 0)); + await tester.pumpAndSettle(); + expect(prefs.getDouble(kMornyeGlassClarityKey), 1); + expect(tester.takeException(), isNull); + }); + + testWidgets('glass clarity explains high contrast ($mode)', (tester) async { + tester.platformDispatcher.accessibilityFeaturesTestValue = + const FakeAccessibilityFeatures(highContrast: true); + addTearDown( + tester.platformDispatcher.clearAccessibilityFeaturesTestValue, + ); + 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(); + expect(tester.widget(slider).onChanged, isNull); + expect( + tester + .widget( + find.byKey(const ValueKey('mornye-glass-clarity-unavailable')), + ) + .data, + 'Glass stays opaque while the system’s high contrast setting is on.', + ); + expect(tester.takeException(), isNull); + }); + testWidgets('Mornye accent updates live and survives restart ($mode)', ( tester, ) async { diff --git a/test/mornye_glass_level_test.dart b/test/mornye_glass_level_test.dart index d0ab0031..b83056d2 100644 --- a/test/mornye_glass_level_test.dart +++ b/test/mornye_glass_level_test.dart @@ -114,6 +114,75 @@ void main() { expect(tester.takeException(), isNull); }); + for (final clarity in [0.0, 0.5]) { + testWidgets('liquid tab and segment pills follow clarity ($clarity)', ( + tester, + ) async { + late BuildContext context; + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: MornyeTheme.build(Brightness.dark, glassClarity: clarity), + home: Scaffold( + body: Builder( + builder: (builderContext) { + context = builderContext; + return Column( + children: [ + MornyeSegmentedControl( + labels: const ['One', 'Two'], + selectedIndex: 0, + onChanged: (_) {}, + ), + MornyeTabBar( + destinations: const [ + NavigationDestination( + icon: Icon(Icons.home), + label: 'Home', + ), + NavigationDestination( + icon: Icon(Icons.music_note), + label: 'Library', + ), + ], + selectedIndex: 0, + onSelected: (_) {}, + blurEnabled: true, + ), + ], + ); + }, + ), + ), + ), + ), + ); + await tester.pump(); + + final liquid = clarity > 0; + // Overlays aligned with the bar share this decision. + expect( + MornyeTabBar.usesLiquidGlass( + context, + blurEnabled: true, + liquidGlass: true, + ), + liquid, + ); + // At 0% the opaque material drops the shader pills, like MornyeGlass + // drops its lens; the resting segments and tabs remain usable. + expect(find.byType(LiquidGlassTabBar), findsNWidgets(liquid ? 2 : 0)); + expect( + find.byType(LiquidGlassLens), + liquid ? findsWidgets : findsNothing, + ); + // The shader bar paints its labels in more than one layer. + expect(find.text('Two'), findsWidgets); + expect(find.text('Library'), findsWidgets); + expect(tester.takeException(), isNull); + }); + } + testWidgets('liquid level keeps the shader glass', (tester) async { await tester.pumpWidget( ProviderScope(