From c89601b6dcac0a9736e6e4924fcfc69f66705154 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sun, 27 Sep 2026 04:32:02 +0700 Subject: [PATCH] feat(mornye): add persistent accent color choices --- lib/l10n/arb/app_en.arb | 7 ++ lib/l10n/arb/app_id.arb | 7 ++ lib/models/theme_settings.dart | 21 +++- lib/providers/theme_provider.dart | 20 ++-- lib/screens/artist_screen.dart | 5 +- lib/screens/concert_detail_screen.dart | 2 +- lib/screens/main_shell.dart | 6 +- lib/screens/now_playing_screen.dart | 9 +- .../settings/appearance_settings_page.dart | 77 ++++++++++++++- lib/screens/upgrade_intro_screen.dart | 2 +- lib/theme/dynamic_color_wrapper.dart | 4 +- lib/theme/mornye_theme.dart | 70 +++++++++++-- lib/widgets/audio_analysis_spectrogram.dart | 5 +- lib/widgets/mornye_artist_header.dart | 5 +- lib/widgets/mornye_context_menu.dart | 2 +- lib/widgets/mornye_player_actions_sheet.dart | 2 +- test/appearance_settings_test.dart | 97 +++++++++++++++++++ test/models_and_utils_test.dart | 8 ++ 18 files changed, 310 insertions(+), 39 deletions(-) create mode 100644 test/appearance_settings_test.dart diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 9850a47f..52cc35dc 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -36,6 +36,13 @@ "description": "System volume control label in the Mornye player" }, "appearanceStyle": "App Style", + "appearanceAccentRed": "Red (default)", + "appearanceAccentOrange": "Orange", + "appearanceAccentGreen": "Green", + "appearanceAccentTeal": "Teal", + "appearanceAccentBlue": "Blue", + "appearanceAccentPurple": "Purple", + "appearanceAccentPink": "Pink", "@appearanceStyle": { "description": "Appearance section for choosing the app visual style" }, diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index ae16f230..ab6e5cc1 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -36,6 +36,13 @@ "description": "Label kontrol volume sistem di pemutar Mornye" }, "appearanceStyle": "Gaya Aplikasi", + "appearanceAccentRed": "Merah (bawaan)", + "appearanceAccentOrange": "Oranye", + "appearanceAccentGreen": "Hijau", + "appearanceAccentTeal": "Hijau kebiruan", + "appearanceAccentBlue": "Biru", + "appearanceAccentPurple": "Ungu", + "appearanceAccentPink": "Merah muda", "@appearanceStyle": { "description": "Bagian tampilan untuk memilih gaya visual aplikasi" }, diff --git a/lib/models/theme_settings.dart b/lib/models/theme_settings.dart index 46cc2f93..ba1d8bb6 100644 --- a/lib/models/theme_settings.dart +++ b/lib/models/theme_settings.dart @@ -5,9 +5,18 @@ const String kUseDynamicColorKey = 'use_dynamic_color'; const String kSeedColorKey = 'seed_color'; const String kUseAmoledKey = 'use_amoled'; const String kThemeStyleKey = 'theme_style'; +const String kMornyeAccentKey = 'mornye_accent'; enum AppThemeStyle { material, mornye } +enum MornyeAccent { red, orange, green, teal, blue, purple, pink } + +MornyeAccent mornyeAccentFromString(String? value) => + MornyeAccent.values.firstWhere( + (accent) => accent.name == value, + orElse: () => MornyeAccent.red, + ); + AppThemeStyle themeStyleFromString(String? value) => AppThemeStyle.values.firstWhere( (style) => style.name == value, @@ -23,6 +32,7 @@ class ThemeSettings { final int seedColorValue; final bool useAmoled; final AppThemeStyle style; + final MornyeAccent mornyeAccent; const ThemeSettings({ this.themeMode = ThemeMode.system, @@ -30,6 +40,7 @@ class ThemeSettings { this.seedColorValue = kDefaultSeedColor, this.useAmoled = false, this.style = AppThemeStyle.material, + this.mornyeAccent = MornyeAccent.red, }); Color get seedColor => Color(seedColorValue); @@ -40,6 +51,7 @@ class ThemeSettings { int? seedColorValue, bool? useAmoled, AppThemeStyle? style, + MornyeAccent? mornyeAccent, }) { return ThemeSettings( themeMode: themeMode ?? this.themeMode, @@ -47,6 +59,7 @@ class ThemeSettings { seedColorValue: seedColorValue ?? this.seedColorValue, useAmoled: useAmoled ?? this.useAmoled, style: style ?? this.style, + mornyeAccent: mornyeAccent ?? this.mornyeAccent, ); } @@ -56,6 +69,7 @@ class ThemeSettings { kSeedColorKey: seedColorValue, kUseAmoledKey: useAmoled, kThemeStyleKey: style.name, + kMornyeAccentKey: mornyeAccent.name, }; factory ThemeSettings.fromJson(Map json) { @@ -65,6 +79,7 @@ class ThemeSettings { seedColorValue: json[kSeedColorKey] as int? ?? kDefaultSeedColor, useAmoled: json[kUseAmoledKey] as bool? ?? false, style: themeStyleFromString(json[kThemeStyleKey] as String?), + mornyeAccent: mornyeAccentFromString(json[kMornyeAccentKey] as String?), ); } @@ -76,7 +91,8 @@ class ThemeSettings { other.useDynamicColor == useDynamicColor && other.seedColorValue == seedColorValue && other.useAmoled == useAmoled && - other.style == style; + other.style == style && + other.mornyeAccent == mornyeAccent; } @override @@ -85,7 +101,8 @@ class ThemeSettings { useDynamicColor.hashCode ^ seedColorValue.hashCode ^ useAmoled.hashCode ^ - style.hashCode; + style.hashCode ^ + mornyeAccent.hashCode; } ThemeMode themeModeFromString(String? value) { diff --git a/lib/providers/theme_provider.dart b/lib/providers/theme_provider.dart index 15331079..a6b761ee 100644 --- a/lib/providers/theme_provider.dart +++ b/lib/providers/theme_provider.dart @@ -14,6 +14,7 @@ ThemeSettings loadBootstrapThemeSettings(SharedPreferences prefs) { seedColorValue: prefs.getInt(kSeedColorKey) ?? kDefaultSeedColor, useAmoled: prefs.getBool(kUseAmoledKey) ?? false, style: themeStyleFromString(prefs.getString(kThemeStyleKey)), + mornyeAccent: mornyeAccentFromString(prefs.getString(kMornyeAccentKey)), ); } @@ -33,18 +34,7 @@ class ThemeNotifier extends Notifier { Future _loadFromStorage() async { try { final prefs = await _prefs; - final modeString = prefs.getString(kThemeModeKey); - final useDynamic = prefs.getBool(kUseDynamicColorKey); - final seedColor = prefs.getInt(kSeedColorKey); - final useAmoled = prefs.getBool(kUseAmoledKey); - - state = ThemeSettings( - themeMode: themeModeFromString(modeString), - useDynamicColor: useDynamic ?? true, - seedColorValue: seedColor ?? kDefaultSeedColor, - useAmoled: useAmoled ?? false, - style: themeStyleFromString(prefs.getString(kThemeStyleKey)), - ); + state = loadBootstrapThemeSettings(prefs); } catch (e) { debugPrint('Error loading theme settings: $e'); } @@ -58,6 +48,7 @@ class ThemeNotifier extends Notifier { await prefs.setInt(kSeedColorKey, state.seedColorValue); await prefs.setBool(kUseAmoledKey, state.useAmoled); await prefs.setString(kThemeStyleKey, state.style.name); + await prefs.setString(kMornyeAccentKey, state.mornyeAccent.name); } catch (e) { debugPrint('Error saving theme settings: $e'); } @@ -92,4 +83,9 @@ class ThemeNotifier extends Notifier { state = state.copyWith(useAmoled: value); await _saveToStorage(); } + + Future setMornyeAccent(MornyeAccent accent) async { + state = state.copyWith(mornyeAccent: accent); + await _saveToStorage(); + } } diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 4510719d..fe369fd5 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -977,7 +977,10 @@ class _ArtistScreenState extends ConsumerState : colorScheme.surfaceContainerHigh, builder: (sheetContext) => sheetContext.isMornye ? Theme( - data: MornyeTheme.build(Brightness.dark), + data: MornyeTheme.fromContext( + sheetContext, + brightness: Brightness.dark, + ), child: MornyeGlassPanel( tintOpacity: 0.78, child: Builder(builder: buildSheet), diff --git a/lib/screens/concert_detail_screen.dart b/lib/screens/concert_detail_screen.dart index b46e34c2..962ec4db 100644 --- a/lib/screens/concert_detail_screen.dart +++ b/lib/screens/concert_detail_screen.dart @@ -83,7 +83,7 @@ class _ConcertDetailScreenState extends State { Widget build(BuildContext context) { final source = _detail.coverUrl ?? widget.coverUrl; final base = context.isMornye - ? MornyeTheme.build(Brightness.dark) + ? MornyeTheme.fromContext(context, brightness: Brightness.dark) : ThemeData.dark(); return Theme( data: base, diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 66275447..fb9140d6 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -996,8 +996,10 @@ class _MainShellState extends ConsumerState builder: (context, child) => Theme( data: ShellNavigationService.chromeBrightness.value == null ? Theme.of(context) - : MornyeTheme.build( - ShellNavigationService.chromeBrightness.value!, + : MornyeTheme.fromContext( + context, + brightness: + ShellNavigationService.chromeBrightness.value!, chromeSurface: ShellNavigationService.chromeSurface.value, ), diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index 3307205b..dfd9c59c 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -549,7 +549,10 @@ class _NowPlayingScreenState extends ConsumerState { Widget build(BuildContext context) { final mornye = context.isMornye; final colorScheme = mornye - ? MornyeTheme.build(Brightness.dark).colorScheme + ? MornyeTheme.fromContext( + context, + brightness: Brightness.dark, + ).colorScheme : Theme.of(context).colorScheme; final mediaItem = ref.watch(currentMediaItemProvider).value; final controller = ref.read(musicPlayerControllerProvider); @@ -633,7 +636,7 @@ class _NowPlayingScreenState extends ConsumerState { ), fullBleed: key == _motionArtworkKey, child: Theme( - data: MornyeTheme.build(Brightness.dark), + data: MornyeTheme.fromContext(context, brightness: Brightness.dark), child: key == _compactArtworkKey ? PlayerArtwork( artUri: mediaItem.artUri?.toString(), @@ -825,7 +828,7 @@ class _NowPlayingScreenState extends ConsumerState { // exposing a detached rectangle. Keep Mornye artwork inside its panel. enabled: false, child: Theme( - data: MornyeTheme.build(Brightness.dark), + data: MornyeTheme.fromContext(context, brightness: Brightness.dark), child: Stack( fit: StackFit.expand, children: [ diff --git a/lib/screens/settings/appearance_settings_page.dart b/lib/screens/settings/appearance_settings_page.dart index e238acba..ad82aafe 100644 --- a/lib/screens/settings/appearance_settings_page.dart +++ b/lib/screens/settings/appearance_settings_page.dart @@ -98,7 +98,23 @@ class AppearanceSettingsPage extends ConsumerWidget { ), ), - if (themeSettings.style == AppThemeStyle.material) ...[ + if (themeSettings.style == AppThemeStyle.mornye) ...[ + SliverToBoxAdapter( + child: SettingsSectionHeader(title: context.l10n.sectionColor), + ), + SliverToBoxAdapter( + child: SettingsGroup( + children: [ + _MornyeAccentPicker( + selected: themeSettings.mornyeAccent, + onChanged: ref + .read(themeProvider.notifier) + .setMornyeAccent, + ), + ], + ), + ), + ] else ...[ SliverToBoxAdapter( child: SettingsSectionHeader(title: context.l10n.sectionColor), ), @@ -456,6 +472,65 @@ class _ThemePreviewCard extends StatelessWidget { } } +class _MornyeAccentPicker extends StatelessWidget { + const _MornyeAccentPicker({required this.selected, required this.onChanged}); + + final MornyeAccent selected; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final labels = { + MornyeAccent.red: l10n.appearanceAccentRed, + MornyeAccent.orange: l10n.appearanceAccentOrange, + MornyeAccent.green: l10n.appearanceAccentGreen, + MornyeAccent.teal: l10n.appearanceAccentTeal, + MornyeAccent.blue: l10n.appearanceAccentBlue, + MornyeAccent.purple: l10n.appearanceAccentPurple, + MornyeAccent.pink: l10n.appearanceAccentPink, + }; + return Padding( + padding: const EdgeInsets.all(16), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final accent in MornyeAccent.values) + Builder( + builder: (context) { + final color = MornyeTheme.accentColor( + accent, + Theme.of(context).brightness, + ); + final foreground = + ThemeData.estimateBrightnessForColor(color) == + Brightness.dark + ? Colors.white + : Colors.black; + return Semantics( + selected: selected == accent, + child: IconButton( + tooltip: labels[accent], + onPressed: () => onChanged(accent), + style: IconButton.styleFrom( + backgroundColor: color, + foregroundColor: foreground, + minimumSize: const Size.square(48), + ), + icon: selected == accent + ? const Icon(Icons.check) + : const SizedBox.square(dimension: 24), + ), + ); + }, + ), + ], + ), + ); + } +} + class _ColorPalettePicker extends StatelessWidget { final int currentColor; final ValueChanged onColorSelected; diff --git a/lib/screens/upgrade_intro_screen.dart b/lib/screens/upgrade_intro_screen.dart index ea64d5d0..015281b7 100644 --- a/lib/screens/upgrade_intro_screen.dart +++ b/lib/screens/upgrade_intro_screen.dart @@ -325,7 +325,7 @@ class _ThemePicker extends ConsumerWidget { padding: const EdgeInsets.only(bottom: 16), child: Theme( data: style == AppThemeStyle.mornye - ? MornyeTheme.build(brightness) + ? MornyeTheme.build(brightness, accent: settings.mornyeAccent) : brightness == Brightness.dark ? AppTheme.dark( seedColor: settings.seedColor, diff --git a/lib/theme/dynamic_color_wrapper.dart b/lib/theme/dynamic_color_wrapper.dart index 6528c59a..c951ebf1 100644 --- a/lib/theme/dynamic_color_wrapper.dart +++ b/lib/theme/dynamic_color_wrapper.dart @@ -19,8 +19,8 @@ class DynamicColorWrapper extends ConsumerWidget { if (themeSettings.style == AppThemeStyle.mornye) { return builder( - MornyeTheme.build(Brightness.light), - MornyeTheme.build(Brightness.dark), + MornyeTheme.build(Brightness.light, accent: themeSettings.mornyeAccent), + MornyeTheme.build(Brightness.dark, accent: themeSettings.mornyeAccent), themeSettings.themeMode, ); } diff --git a/lib/theme/mornye_theme.dart b/lib/theme/mornye_theme.dart index eb2da827..5c585fd7 100644 --- a/lib/theme/mornye_theme.dart +++ b/lib/theme/mornye_theme.dart @@ -1,6 +1,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:spotiflac_android/models/theme_settings.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; /// Flutter counterpart of Mornye's iPhone visual system. Reference values come @@ -8,9 +9,10 @@ import 'package:spotiflac_android/theme/app_tokens.dart'; /// in the Mornye project; keep this palette independent of wallpaper colors. @immutable class MornyeTheme extends ThemeExtension { - const MornyeTheme({this.chromeSurface}); + const MornyeTheme({this.chromeSurface, this.accent = MornyeAccent.red}); final Color? chromeSurface; + final MornyeAccent accent; /// A single translucent fill for controls inside an existing glass surface. static Color controlFill(BuildContext context, {bool enabled = true}) { @@ -45,6 +47,23 @@ class MornyeTheme extends ThemeExtension { static const lightAccent = Color.fromRGBO(204, 46, 51, 1); static const darkAccent = Color.fromRGBO(224, 61, 60, 1); + static Color accentColor(MornyeAccent accent, Brightness brightness) { + final dark = brightness == Brightness.dark; + final color = switch (accent) { + MornyeAccent.red => const CupertinoDynamicColor.withBrightness( + color: lightAccent, + darkColor: darkAccent, + ), + MornyeAccent.orange => CupertinoColors.systemOrange, + MornyeAccent.green => CupertinoColors.systemGreen, + MornyeAccent.teal => CupertinoColors.systemTeal, + MornyeAccent.blue => CupertinoColors.systemBlue, + MornyeAccent.purple => CupertinoColors.systemPurple, + MornyeAccent.pink => CupertinoColors.systemPink, + }; + return dark ? color.darkColor : color.color; + } + static final tokens = AppTokens.standard.copyWith( radiusBadge: 5, radiusThumb: 6, @@ -78,16 +97,24 @@ class MornyeTheme extends ThemeExtension { // many artists cannot grow the cache indefinitely. Platform is part of the // key because it controls fonts and route transitions. static final _themeCache = - <(TargetPlatform, Brightness, Color?), ThemeData>{}; + <(TargetPlatform, Brightness, Color?, MornyeAccent), ThemeData>{}; - static ThemeData build(Brightness brightness, {Color? chromeSurface}) { - final key = (defaultTargetPlatform, brightness, chromeSurface); + static ThemeData build( + Brightness brightness, { + Color? chromeSurface, + MornyeAccent accent = MornyeAccent.red, + }) { + final key = (defaultTargetPlatform, brightness, chromeSurface, accent); final cached = _themeCache.remove(key); if (cached != null) { _themeCache[key] = cached; return cached; } - final theme = _build(brightness, chromeSurface: chromeSurface); + final theme = _build( + brightness, + chromeSurface: chromeSurface, + selectedAccent: accent, + ); if (_themeCache.length >= 16) { _themeCache.remove(_themeCache.keys.first); } @@ -95,9 +122,28 @@ class MornyeTheme extends ThemeExtension { return theme; } - static ThemeData _build(Brightness brightness, {Color? chromeSurface}) { + /// Rebuild local surfaces without losing the user's Mornye preferences. + static ThemeData fromContext( + BuildContext context, { + Brightness? brightness, + Color? chromeSurface, + }) { + final theme = Theme.of(context); + final preferences = theme.extension(); + return build( + brightness ?? theme.brightness, + chromeSurface: chromeSurface, + accent: preferences?.accent ?? MornyeAccent.red, + ); + } + + static ThemeData _build( + Brightness brightness, { + Color? chromeSurface, + required MornyeAccent selectedAccent, + }) { final dark = brightness == Brightness.dark; - final accent = dark ? darkAccent : lightAccent; + final accent = accentColor(selectedAccent, brightness); final foreground = dark ? Colors.white : Colors.black; final surface = dark ? Colors.black : Colors.white; final grouped = dark ? const Color(0xff1c1c1e) : const Color(0xfff2f2f7); @@ -197,7 +243,7 @@ class MornyeTheme extends ThemeExtension { fontFamily: text.fontFamily, splashFactory: NoSplash.splashFactory, extensions: >[ - MornyeTheme(chromeSurface: chromeSurface), + MornyeTheme(chromeSurface: chromeSurface, accent: selectedAccent), tokens, ], ); @@ -289,12 +335,16 @@ class MornyeTheme extends ThemeExtension { } @override - MornyeTheme copyWith({Color? chromeSurface}) => - MornyeTheme(chromeSurface: chromeSurface ?? this.chromeSurface); + MornyeTheme copyWith({Color? chromeSurface, MornyeAccent? accent}) => + MornyeTheme( + chromeSurface: chromeSurface ?? this.chromeSurface, + accent: accent ?? this.accent, + ); @override MornyeTheme lerp(covariant MornyeTheme? other, double t) => MornyeTheme( chromeSurface: Color.lerp(chromeSurface, other?.chromeSurface, t), + accent: t < 0.5 ? accent : other?.accent ?? accent, ); } diff --git a/lib/widgets/audio_analysis_spectrogram.dart b/lib/widgets/audio_analysis_spectrogram.dart index 636e5468..401ea37b 100644 --- a/lib/widgets/audio_analysis_spectrogram.dart +++ b/lib/widgets/audio_analysis_spectrogram.dart @@ -147,7 +147,10 @@ class _SpectrogramView extends StatelessWidget { // The plot stays black in both appearances; its controls need matching // contrast even when the surrounding Mornye page uses the light theme. return context.isMornye - ? Theme(data: MornyeTheme.build(Brightness.dark), child: card) + ? Theme( + data: MornyeTheme.fromContext(context, brightness: Brightness.dark), + child: card, + ) : card; } diff --git a/lib/widgets/mornye_artist_header.dart b/lib/widgets/mornye_artist_header.dart index 25f09301..b6954330 100644 --- a/lib/widgets/mornye_artist_header.dart +++ b/lib/widgets/mornye_artist_header.dart @@ -40,7 +40,10 @@ class _MornyeArtistSurfaceState extends State { @override Widget build(BuildContext context) { - final darkTheme = MornyeTheme.build(Brightness.dark); + final darkTheme = MornyeTheme.fromContext( + context, + brightness: Brightness.dark, + ); return Theme( data: darkTheme.copyWith( colorScheme: darkTheme.colorScheme.copyWith(primary: Colors.grey), diff --git a/lib/widgets/mornye_context_menu.dart b/lib/widgets/mornye_context_menu.dart index 38039a5d..e91ce9f4 100644 --- a/lib/widgets/mornye_context_menu.dart +++ b/lib/widgets/mornye_context_menu.dart @@ -172,7 +172,7 @@ class MornyeContextMenu extends StatelessWidget { Widget build(BuildContext context) { final theme = inheritSurface ? Theme.of(context) - : MornyeTheme.build(Theme.of(context).brightness); + : MornyeTheme.fromContext(context); final visibleGroups = groups.where((group) => group.isNotEmpty).toList(); final shortcuts = quickActions.isEmpty ? null diff --git a/lib/widgets/mornye_player_actions_sheet.dart b/lib/widgets/mornye_player_actions_sheet.dart index 69be7e9a..64268344 100644 --- a/lib/widgets/mornye_player_actions_sheet.dart +++ b/lib/widgets/mornye_player_actions_sheet.dart @@ -17,7 +17,7 @@ class MornyePlayerNavigationMenu extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = MornyeTheme.build(Brightness.dark); + final theme = MornyeTheme.fromContext(context, brightness: Brightness.dark); final art = mediaItem.artUri; final source = art?.scheme == 'file' ? art!.toFilePath() : art?.toString(); return Theme( diff --git a/test/appearance_settings_test.dart b/test/appearance_settings_test.dart new file mode 100644 index 00000000..8ebc5c4a --- /dev/null +++ b/test/appearance_settings_test.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.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/l10n/l10n.dart'; +import 'package:spotiflac_android/models/theme_settings.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'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; + +void main() { + Future openSettings( + WidgetTester tester, + SharedPreferences prefs, + ) async { + tester.view.physicalSize = const Size(390, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget( + ProviderScope( + overrides: [ + initialThemeSettingsProvider.overrideWithValue( + loadBootstrapThemeSettings(prefs), + ), + ], + child: DynamicColorWrapper( + builder: (light, dark, mode) => MaterialApp( + theme: light, + darkTheme: dark, + themeMode: mode, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const MornyeSettingsTheme(child: AppearanceSettingsPage()), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + for (final mode in [ThemeMode.light, ThemeMode.dark]) { + testWidgets('Mornye accent updates live and survives restart ($mode)', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({ + kThemeStyleKey: 'mornye', + kThemeModeKey: mode.name, + kSeedColorKey: 0xff123456, + }); + final prefs = await SharedPreferences.getInstance(); + await openSettings(tester, prefs); + final page = find.byType(AppearanceSettingsPage); + final initial = Theme.of(tester.element(page)); + expect( + initial.colorScheme.primary, + MornyeTheme.accentColor(MornyeAccent.red, initial.brightness), + ); + + final blue = find.byTooltip('Blue'); + await tester.ensureVisible(blue); + await tester.tap(blue); + await tester.pumpAndSettle(); + final theme = Theme.of(tester.element(page)); + expect( + theme.colorScheme.primary, + MornyeTheme.accentColor(MornyeAccent.blue, theme.brightness), + ); + expect(prefs.getString(kMornyeAccentKey), 'blue'); + expect(prefs.getInt(kSeedColorKey), 0xff123456); + final overlay = MornyeTheme.fromContext( + tester.element(page), + brightness: Brightness.dark, + ); + expect(overlay.extension()!.accent, MornyeAccent.blue); + expect( + overlay.colorScheme.primary, + MornyeTheme.accentColor(MornyeAccent.blue, Brightness.dark), + ); + + await tester.pumpWidget(const SizedBox()); + await openSettings(tester, prefs); + expect( + Theme.of(tester.element(page)).colorScheme.primary, + theme.colorScheme.primary, + ); + await tester.ensureVisible(find.byTooltip('Red (default)')); + await tester.tap(find.byTooltip('Red (default)')); + await tester.pumpAndSettle(); + expect( + Theme.of(tester.element(page)).colorScheme.primary, + initial.colorScheme.primary, + ); + expect(tester.takeException(), isNull); + }); + } +} diff --git a/test/models_and_utils_test.dart b/test/models_and_utils_test.dart index 70f4eae2..ed5b10fa 100644 --- a/test/models_and_utils_test.dart +++ b/test/models_and_utils_test.dart @@ -1108,6 +1108,7 @@ void main() { seedColorValue: 0xff123456, useAmoled: true, style: AppThemeStyle.mornye, + mornyeAccent: MornyeAccent.blue, ); final decoded = ThemeSettings.fromJson(settings.toJson()); @@ -1119,6 +1120,13 @@ void main() { expect(copied.themeMode, ThemeMode.light); expect(copied.useAmoled, isTrue); expect(decoded.style, AppThemeStyle.mornye); + expect(decoded.mornyeAccent, MornyeAccent.blue); + expect(copied.mornyeAccent, MornyeAccent.blue); + expect(ThemeSettings.fromJson({}).mornyeAccent, MornyeAccent.red); + expect( + ThemeSettings.fromJson({kMornyeAccentKey: 'unknown'}).mornyeAccent, + MornyeAccent.red, + ); expect( ThemeSettings.fromJson({'theme_mode': 'invalid'}).themeMode, ThemeMode.system,