mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-02 14:19:45 +02:00
fix(mornye): respect glass clarity and accessibility limits
This commit is contained in:
1 parent
7a7dd99eab
commit
a2974acee0
7 files changed
+218
-15
No files matched your search
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<NavigationDestination> destinations;
|
||||
final int selectedIndex;
|
||||
final ValueChanged<int> 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
|
||||
|
||||
@@ -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<void> 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>(slider).onChanged, isNull);
|
||||
expect(
|
||||
tester.widget<Text>(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>(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>(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>(slider).onChanged, isNull);
|
||||
expect(
|
||||
tester
|
||||
.widget<Text>(
|
||||
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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user