feat(mornye): add persistent accent color choices

This commit is contained in:
zarzet committed 2026-09-27 04:32:02 +07:00
1 parent 6b53eb9c06
commit c89601b6dc
18 files changed
+310 -39

No files matched your search

+7
View File
@@ -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"
},
+7
View File
@@ -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"
},
+19 -2
View File
@@ -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<String, dynamic> 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) {
+8 -12
View File
@@ -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<ThemeSettings> {
Future<void> _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<ThemeSettings> {
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<ThemeSettings> {
state = state.copyWith(useAmoled: value);
await _saveToStorage();
}
Future<void> setMornyeAccent(MornyeAccent accent) async {
state = state.copyWith(mornyeAccent: accent);
await _saveToStorage();
}
}
+4 -1
View File
@@ -977,7 +977,10 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen>
: 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),
+1 -1
View File
@@ -83,7 +83,7 @@ class _ConcertDetailScreenState extends State<ConcertDetailScreen> {
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,
+4 -2
View File
@@ -996,8 +996,10 @@ class _MainShellState extends ConsumerState<MainShell>
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,
),
+6 -3
View File
@@ -549,7 +549,10 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
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<NowPlayingScreen> {
),
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<NowPlayingScreen> {
// 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: [
@@ -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<MornyeAccent> 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<Color> onColorSelected;
+1 -1
View File
@@ -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,
+2 -2
View File
@@ -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,
);
}
+60 -10
View File
@@ -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<MornyeTheme> {
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<MornyeTheme> {
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<MornyeTheme> {
// 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<MornyeTheme> {
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<MornyeTheme>();
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<MornyeTheme> {
fontFamily: text.fontFamily,
splashFactory: NoSplash.splashFactory,
extensions: <ThemeExtension<dynamic>>[
MornyeTheme(chromeSurface: chromeSurface),
MornyeTheme(chromeSurface: chromeSurface, accent: selectedAccent),
tokens,
],
);
@@ -289,12 +335,16 @@ class MornyeTheme extends ThemeExtension<MornyeTheme> {
}
@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,
);
}
+4 -1
View File
@@ -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;
}
+4 -1
View File
@@ -40,7 +40,10 @@ class _MornyeArtistSurfaceState extends State<MornyeArtistSurface> {
@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),
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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(
+97
View File
@@ -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<void> 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<MornyeTheme>()!.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);
});
}
}
+8
View File
@@ -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,