fix(mornye): keep popover glass clear and correctly clipped

This commit is contained in:
zarzet committed 2026-09-27 05:00:06 +07:00
1 parent ebef6ec317
commit 48891a1bb3
5 files changed
+151 -18

No files matched your search

-1
View File
@@ -1927,7 +1927,6 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
maxWidth: menuWidth, maxWidth: menuWidth,
builder: (menuContext) => MornyeContextMenu( builder: (menuContext) => MornyeContextMenu(
dense: true, dense: true,
liquidGlass: true,
groups: [ groups: [
[ [
for (final (value, label, icon) in actions) for (final (value, label, icon) in actions)
+16 -1
View File
@@ -130,6 +130,7 @@ class MornyeGlassPanel extends ConsumerWidget {
this.lastInGroup = true, this.lastInGroup = true,
this.strongTint = false, this.strongTint = false,
this.tintOpacity, this.tintOpacity,
this.tintColor,
this.liquidGlass = false, this.liquidGlass = false,
this.blurEnabled = true, this.blurEnabled = true,
}); });
@@ -142,6 +143,7 @@ class MornyeGlassPanel extends ConsumerWidget {
this.firstInGroup = true, this.firstInGroup = true,
this.lastInGroup = true, this.lastInGroup = true,
this.tintOpacity = 0.78, this.tintOpacity = 0.78,
this.tintColor,
this.liquidGlass = false, this.liquidGlass = false,
this.blurEnabled = true, this.blurEnabled = true,
}) : strongTint = false; }) : strongTint = false;
@@ -152,6 +154,7 @@ class MornyeGlassPanel extends ConsumerWidget {
final bool lastInGroup; final bool lastInGroup;
final bool strongTint; final bool strongTint;
final double? tintOpacity; final double? tintOpacity;
final Color? tintColor;
final bool liquidGlass; final bool liquidGlass;
/// Disable backdrop sampling for surfaces that scroll over a plain page. /// Disable backdrop sampling for surfaces that scroll over a plain page.
@@ -168,6 +171,7 @@ class MornyeGlassPanel extends ConsumerWidget {
return MornyeGlass( return MornyeGlass(
radius: radius, radius: radius,
tintOpacity: tintOpacity, tintOpacity: tintOpacity,
tintColor: tintColor,
blurEnabled: blur, blurEnabled: blur,
child: Material(color: Colors.transparent, child: child), child: Material(color: Colors.transparent, child: child),
); );
@@ -178,6 +182,7 @@ class MornyeGlassPanel extends ConsumerWidget {
lastInGroup: lastInGroup, lastInGroup: lastInGroup,
strongTint: strongTint, strongTint: strongTint,
tintOpacity: tintOpacity, tintOpacity: tintOpacity,
tintColor: tintColor,
blurEnabled: blur, blurEnabled: blur,
child: Material( child: Material(
color: strongTint color: strongTint
@@ -326,9 +331,14 @@ class _MornyeGlassSurface extends StatelessWidget {
left: rim, left: rim,
right: 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( final surface = DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
color: (tintColor ?? scheme.surfaceContainerHigh).withValues( color: tint.withValues(
alpha: useBlur ? (tintOpacity ?? (strongTint ? 0.80 : 0.60)) : 1, alpha: useBlur ? (tintOpacity ?? (strongTint ? 0.80 : 0.60)) : 1,
), ),
gradient: useBlur && !dark && tintOpacity == null gradient: useBlur && !dark && tintOpacity == null
@@ -359,6 +369,11 @@ class _MornyeGlassSurface extends StatelessWidget {
BoxShadow( BoxShadow(
color: Colors.black.withValues(alpha: dark ? 0.2 : 0.06), color: Colors.black.withValues(alpha: dark ? 0.2 : 0.06),
blurRadius: dark ? 18 : 10, 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), offset: Offset(0, dark ? 4 : 2),
), ),
] ]
+9 -9
View File
@@ -159,14 +159,12 @@ class MornyeContextMenu extends StatelessWidget {
required this.groups, required this.groups,
this.inheritSurface = false, this.inheritSurface = false,
this.dense = false, this.dense = false,
this.liquidGlass = true,
}); });
final List<MornyeMenuAction> quickActions; final List<MornyeMenuAction> quickActions;
final List<List<MornyeMenuAction>> groups; final List<List<MornyeMenuAction>> groups;
final bool inheritSurface; final bool inheritSurface;
final bool dense; final bool dense;
final bool liquidGlass;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -211,12 +209,10 @@ class MornyeContextMenu extends StatelessWidget {
data: theme, data: theme,
child: MornyeGlassPanel.overlay( child: MornyeGlassPanel.overlay(
radius: 28, radius: 28,
liquidGlass: liquidGlass, // Keep one local, clipped backdrop. A second screen-space lens pass
tintOpacity: liquidGlass // can shift its outline inside an offset/scaled popover on Impeller.
? theme.brightness == Brightness.dark tintColor: Colors.white,
? 0.30 tintOpacity: theme.brightness == Brightness.dark ? 0.12 : 0.50,
: 0.52
: 0.78,
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
// Let the entire menu scroll when pinning the shortcuts would // Let the entire menu scroll when pinning the shortcuts would
@@ -292,7 +288,11 @@ class MornyeContextMenu extends StatelessWidget {
const SizedBox(height: 3), const SizedBox(height: 3),
Text( Text(
action.subtitle!, action.subtitle!,
style: theme.textTheme.bodySmall, style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withValues(
alpha: 0.78,
),
),
), ),
], ],
], ],
+125 -6
View File
@@ -1,8 +1,12 @@
import 'dart:ui' as ui;
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.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/theme/mornye_theme.dart';
import 'package:spotiflac_android/widgets/mornye_context_menu.dart'; import 'package:spotiflac_android/widgets/mornye_context_menu.dart';
@@ -18,23 +22,36 @@ void main() {
double textScale = 1, double textScale = 1,
bool reduceMotion = false, bool reduceMotion = false,
bool preferAbove = false, bool preferAbove = false,
bool highContrast = false,
bool lowEnd = false,
Color? backgroundColor,
GlobalKey? capture,
}) async { }) async {
tester.view.physicalSize = size; tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset); addTearDown(tester.view.reset);
await tester.pumpWidget( await tester.pumpWidget(
ProviderScope( ProviderScope(
overrides: [
lowEndDeviceProvider.overrideWithValue(lowEnd),
backdropBlurEnabledProvider.overrideWithValue(false),
],
child: MaterialApp( child: MaterialApp(
theme: MornyeTheme.build(brightness), theme: MornyeTheme.build(brightness),
builder: (context, child) => MediaQuery( builder: (context, child) => RepaintBoundary(
data: MediaQuery.of(context).copyWith( key: capture,
padding: const EdgeInsets.fromLTRB(0, 24, 0, 24), child: MediaQuery(
textScaler: TextScaler.linear(textScale), data: MediaQuery.of(context).copyWith(
disableAnimations: reduceMotion, padding: const EdgeInsets.fromLTRB(0, 24, 0, 24),
textScaler: TextScaler.linear(textScale),
disableAnimations: reduceMotion,
highContrast: highContrast,
),
child: child!,
), ),
child: child!,
), ),
home: Scaffold( home: Scaffold(
backgroundColor: backgroundColor,
body: Builder( body: Builder(
builder: (context) => Center( builder: (context) => Center(
child: TextButton( child: TextButton(
@@ -91,7 +108,76 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
} }
Future<List<Color>> samplePixels(
WidgetTester tester,
GlobalKey capture,
List<Offset> positions,
) async => (await tester.runAsync(() async {
final boundary = tester.renderObject<RenderRepaintBoundary>(
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) { 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)', ( testWidgets('menu stays on screen near a bottom edge ($brightness)', (
tester, tester,
) async { ) 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( testWidgets(
'large text in landscape keeps shortcuts and last action usable', 'large text in landscape keeps shortcuts and last action usable',
(tester) async { (tester) async {
+1 -1
View File
@@ -2608,7 +2608,7 @@ void main() {
final glass = tester.widget<MornyeGlassPanel>( final glass = tester.widget<MornyeGlassPanel>(
find.byType(MornyeGlassPanel).last, find.byType(MornyeGlassPanel).last,
); );
expect(glass.liquidGlass, isTrue); expect(glass.tintColor, Colors.white);
expect(glass.tintOpacity, lessThan(0.5)); expect(glass.tintOpacity, lessThan(0.5));
expect( expect(
find.text('Hide Pronunciation'), find.text('Hide Pronunciation'),