From 48891a1bb31bd9ef850c8a2bb5a83d98f362fac2 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sun, 27 Sep 2026 05:00:06 +0700 Subject: [PATCH] fix(mornye): keep popover glass clear and correctly clipped --- lib/screens/now_playing_screen.dart | 1 - lib/widgets/mornye_chrome.dart | 17 ++- lib/widgets/mornye_context_menu.dart | 18 +-- test/mornye_context_menu_test.dart | 131 ++++++++++++++++++- test/now_playing_lyrics_transition_test.dart | 2 +- 5 files changed, 151 insertions(+), 18 deletions(-) diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index dfd9c59c..9642d4bd 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -1927,7 +1927,6 @@ class _NowPlayingScreenState extends ConsumerState { maxWidth: menuWidth, builder: (menuContext) => MornyeContextMenu( dense: true, - liquidGlass: true, groups: [ [ for (final (value, label, icon) in actions) diff --git a/lib/widgets/mornye_chrome.dart b/lib/widgets/mornye_chrome.dart index 24da120a..35ed58d1 100644 --- a/lib/widgets/mornye_chrome.dart +++ b/lib/widgets/mornye_chrome.dart @@ -130,6 +130,7 @@ class MornyeGlassPanel extends ConsumerWidget { this.lastInGroup = true, this.strongTint = false, this.tintOpacity, + this.tintColor, this.liquidGlass = false, this.blurEnabled = true, }); @@ -142,6 +143,7 @@ class MornyeGlassPanel extends ConsumerWidget { this.firstInGroup = true, this.lastInGroup = true, this.tintOpacity = 0.78, + this.tintColor, this.liquidGlass = false, this.blurEnabled = true, }) : strongTint = false; @@ -152,6 +154,7 @@ class MornyeGlassPanel extends ConsumerWidget { final bool lastInGroup; final bool strongTint; final double? tintOpacity; + final Color? tintColor; final bool liquidGlass; /// Disable backdrop sampling for surfaces that scroll over a plain page. @@ -168,6 +171,7 @@ class MornyeGlassPanel extends ConsumerWidget { return MornyeGlass( radius: radius, tintOpacity: tintOpacity, + tintColor: tintColor, blurEnabled: blur, child: Material(color: Colors.transparent, child: child), ); @@ -178,6 +182,7 @@ class MornyeGlassPanel extends ConsumerWidget { lastInGroup: lastInGroup, strongTint: strongTint, tintOpacity: tintOpacity, + tintColor: tintColor, blurEnabled: blur, child: Material( color: strongTint @@ -326,9 +331,14 @@ class _MornyeGlassSurface extends StatelessWidget { 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 tint = useBlur + ? tintColor ?? scheme.surfaceContainerHigh + : scheme.surfaceContainerHigh; final surface = DecoratedBox( decoration: BoxDecoration( - color: (tintColor ?? scheme.surfaceContainerHigh).withValues( + color: tint.withValues( alpha: useBlur ? (tintOpacity ?? (strongTint ? 0.80 : 0.60)) : 1, ), gradient: useBlur && !dark && tintOpacity == null @@ -359,6 +369,11 @@ class _MornyeGlassSurface extends StatelessWidget { BoxShadow( color: Colors.black.withValues(alpha: dark ? 0.2 : 0.06), blurRadius: dark ? 18 : 10, + // 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), ), ] diff --git a/lib/widgets/mornye_context_menu.dart b/lib/widgets/mornye_context_menu.dart index 3007c62f..98521f8e 100644 --- a/lib/widgets/mornye_context_menu.dart +++ b/lib/widgets/mornye_context_menu.dart @@ -159,14 +159,12 @@ class MornyeContextMenu extends StatelessWidget { required this.groups, this.inheritSurface = false, this.dense = false, - this.liquidGlass = true, }); final List quickActions; final List> groups; final bool inheritSurface; final bool dense; - final bool liquidGlass; @override Widget build(BuildContext context) { @@ -211,12 +209,10 @@ class MornyeContextMenu extends StatelessWidget { data: theme, child: MornyeGlassPanel.overlay( radius: 28, - liquidGlass: liquidGlass, - tintOpacity: liquidGlass - ? theme.brightness == Brightness.dark - ? 0.30 - : 0.52 - : 0.78, + // Keep one local, clipped backdrop. A second screen-space lens pass + // can shift its outline inside an offset/scaled popover on Impeller. + tintColor: Colors.white, + tintOpacity: theme.brightness == Brightness.dark ? 0.12 : 0.50, child: LayoutBuilder( builder: (context, constraints) { // Let the entire menu scroll when pinning the shortcuts would @@ -292,7 +288,11 @@ class MornyeContextMenu extends StatelessWidget { const SizedBox(height: 3), Text( action.subtitle!, - style: theme.textTheme.bodySmall, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withValues( + alpha: 0.78, + ), + ), ), ], ], diff --git a/test/mornye_context_menu_test.dart b/test/mornye_context_menu_test.dart index f5e704a9..97e6590e 100644 --- a/test/mornye_context_menu_test.dart +++ b/test/mornye_context_menu_test.dart @@ -1,8 +1,12 @@ +import 'dart:ui' as ui; + import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +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/providers/runtime_profile_provider.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/widgets/mornye_context_menu.dart'; @@ -18,23 +22,36 @@ void main() { double textScale = 1, bool reduceMotion = false, bool preferAbove = false, + bool highContrast = false, + bool lowEnd = false, + Color? backgroundColor, + GlobalKey? capture, }) async { tester.view.physicalSize = size; tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); await tester.pumpWidget( ProviderScope( + overrides: [ + lowEndDeviceProvider.overrideWithValue(lowEnd), + backdropBlurEnabledProvider.overrideWithValue(false), + ], child: MaterialApp( theme: MornyeTheme.build(brightness), - builder: (context, child) => MediaQuery( - data: MediaQuery.of(context).copyWith( - padding: const EdgeInsets.fromLTRB(0, 24, 0, 24), - textScaler: TextScaler.linear(textScale), - disableAnimations: reduceMotion, + builder: (context, child) => RepaintBoundary( + key: capture, + child: MediaQuery( + data: MediaQuery.of(context).copyWith( + padding: const EdgeInsets.fromLTRB(0, 24, 0, 24), + textScaler: TextScaler.linear(textScale), + disableAnimations: reduceMotion, + highContrast: highContrast, + ), + child: child!, ), - child: child!, ), home: Scaffold( + backgroundColor: backgroundColor, body: Builder( builder: (context) => Center( child: TextButton( @@ -91,7 +108,76 @@ void main() { await tester.pumpAndSettle(); } + Future> samplePixels( + WidgetTester tester, + GlobalKey capture, + List positions, + ) async => (await tester.runAsync(() async { + final boundary = tester.renderObject( + find.byKey(capture), + ); + final image = await boundary.toImage(); + try { + final pixels = (await image.toByteData( + format: ui.ImageByteFormat.rawRgba, + ))!; + return positions.map((position) { + final local = boundary.globalToLocal(position); + final index = (local.dy.floor() * image.width + local.dx.floor()) * 4; + return Color.fromARGB( + pixels.getUint8(index + 3), + pixels.getUint8(index), + pixels.getUint8(index + 1), + pixels.getUint8(index + 2), + ); + }).toList(); + } finally { + image.dispose(); + } + }))!; + for (final brightness in Brightness.values) { + testWidgets('menu glass lightens its backdrop evenly ($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: const Color(0xff6c3a22), + capture: capture, + onResult: (_) {}, + ); + final menu = tester.getRect(find.byType(MornyeContextMenu)); + final pixels = await samplePixels(tester, capture, [ + const Offset(8, 80), + 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 outside = pixels.first; + for (final inside in pixels.skip(1)) { + // The white wash lightens all channels while retaining the backdrop's + // warm hue. Sampling both sides catches an extra, offset glass panel. + expect(inside.r, greaterThan(outside.r + 0.04)); + expect(inside.g, greaterThan(outside.g + 0.04)); + expect(inside.b, greaterThan(outside.b + 0.04)); + expect(inside.r, greaterThan(inside.g + 0.04)); + expect(inside.g, greaterThan(inside.b + 0.02)); + expect(inside.r, closeTo(pixels[1].r, 0.015)); + expect(inside.g, closeTo(pixels[1].g, 0.015)); + expect(inside.b, closeTo(pixels[1].b, 0.015)); + } + expect(tester.takeException(), isNull); + } finally { + debugDisableShadows = originalDisableShadows; + } + }); + testWidgets('menu stays on screen near a bottom edge ($brightness)', ( tester, ) async { @@ -117,6 +203,39 @@ void main() { }); } + for (final highContrast in [false, true]) { + testWidgets('menu stays readable without blur (contrast: $highContrast)', ( + tester, + ) async { + final capture = GlobalKey(); + await openMenu( + tester, + anchor: const Rect.fromLTWH(330, 144, 44, 44), + brightness: Brightness.dark, + backgroundColor: const Color(0xff6c3a22), + highContrast: highContrast, + lowEnd: !highContrast, + capture: capture, + onResult: (_) {}, + ); + final menu = find.byType(MornyeContextMenu); + final rect = tester.getRect(menu); + final pixels = await samplePixels(tester, capture, [ + Offset(rect.left + 8, rect.center.dy), + ]); + final theme = MornyeTheme.build(Brightness.dark); + expect(pixels.single, theme.colorScheme.surfaceContainerHigh); + expect( + find.descendant(of: menu, matching: find.byType(BackdropFilter)), + findsNothing, + ); + await tester.tap(find.text('Share')); + await tester.pumpAndSettle(); + expect(menu, findsNothing); + expect(tester.takeException(), isNull); + }); + } + testWidgets( 'large text in landscape keeps shortcuts and last action usable', (tester) async { diff --git a/test/now_playing_lyrics_transition_test.dart b/test/now_playing_lyrics_transition_test.dart index 9fbb585d..f2aed50f 100644 --- a/test/now_playing_lyrics_transition_test.dart +++ b/test/now_playing_lyrics_transition_test.dart @@ -2608,7 +2608,7 @@ void main() { final glass = tester.widget( find.byType(MornyeGlassPanel).last, ); - expect(glass.liquidGlass, isTrue); + expect(glass.tintColor, Colors.white); expect(glass.tintOpacity, lessThan(0.5)); expect( find.text('Hide Pronunciation'),