mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 13:19:44 +02:00
fix(mornye): keep popover glass clear and correctly clipped
This commit is contained in:
1 parent
ebef6ec317
commit
48891a1bb3
5 files changed
+151
-18
No files matched your search
@@ -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)
|
||||||
|
|||||||
@@ -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),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
Reference in new issue
Block a user