mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-04 23:26:58 +02:00
feat(appearance): add glass clarity controls and directional highlights
This commit is contained in:
1 parent
9ebf98ff6c
commit
ce13e29b71
12 files changed
+526
-225
No files matched your search
@@ -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>(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<MornyeTheme>()!.glassClarity,
|
||||
1,
|
||||
);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
await openSettings(tester, prefs);
|
||||
expect(tester.widget<Slider>(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>(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();
|
||||
|
||||
@@ -43,6 +43,7 @@ void main() {
|
||||
Color? backdrop,
|
||||
Color? chromeSurface,
|
||||
Widget? body,
|
||||
double glassClarity = 0.75,
|
||||
ValueNotifier<int>? 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<Text>(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<RenderRepaintBoundary>(
|
||||
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 = <Color>[];
|
||||
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<Text>(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<RenderRepaintBoundary>(
|
||||
find.byKey(capture),
|
||||
);
|
||||
final image = await boundary.toImage();
|
||||
final bytes = (await image.toByteData(
|
||||
format: ui.ImageByteFormat.rawRgba,
|
||||
))!;
|
||||
final colors = <Color>[];
|
||||
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);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)', (
|
||||
|
||||
Reference in new issue
Block a user