diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 52cc35dc..582c5a70 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -43,6 +43,8 @@ "appearanceAccentBlue": "Blue", "appearanceAccentPurple": "Purple", "appearanceAccentPink": "Pink", + "appearanceUseSystemFont": "Use system font", + "appearanceUseSystemFontSubtitle": "Use your device’s default typeface for app text.", "@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 ab6e5cc1..3b6221d1 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -43,6 +43,8 @@ "appearanceAccentBlue": "Biru", "appearanceAccentPurple": "Ungu", "appearanceAccentPink": "Merah muda", + "appearanceUseSystemFont": "Gunakan font sistem", + "appearanceUseSystemFontSubtitle": "Gunakan jenis huruf bawaan perangkat untuk teks aplikasi.", "@appearanceStyle": { "description": "Bagian tampilan untuk memilih gaya visual aplikasi" }, diff --git a/lib/models/theme_settings.dart b/lib/models/theme_settings.dart index ba1d8bb6..d04c9e3d 100644 --- a/lib/models/theme_settings.dart +++ b/lib/models/theme_settings.dart @@ -6,6 +6,7 @@ const String kSeedColorKey = 'seed_color'; const String kUseAmoledKey = 'use_amoled'; const String kThemeStyleKey = 'theme_style'; const String kMornyeAccentKey = 'mornye_accent'; +const String kUseSystemFontKey = 'use_system_font'; enum AppThemeStyle { material, mornye } @@ -33,6 +34,7 @@ class ThemeSettings { final bool useAmoled; final AppThemeStyle style; final MornyeAccent mornyeAccent; + final bool useSystemFont; const ThemeSettings({ this.themeMode = ThemeMode.system, @@ -41,6 +43,7 @@ class ThemeSettings { this.useAmoled = false, this.style = AppThemeStyle.material, this.mornyeAccent = MornyeAccent.red, + this.useSystemFont = false, }); Color get seedColor => Color(seedColorValue); @@ -52,6 +55,7 @@ class ThemeSettings { bool? useAmoled, AppThemeStyle? style, MornyeAccent? mornyeAccent, + bool? useSystemFont, }) { return ThemeSettings( themeMode: themeMode ?? this.themeMode, @@ -60,6 +64,7 @@ class ThemeSettings { useAmoled: useAmoled ?? this.useAmoled, style: style ?? this.style, mornyeAccent: mornyeAccent ?? this.mornyeAccent, + useSystemFont: useSystemFont ?? this.useSystemFont, ); } @@ -70,6 +75,7 @@ class ThemeSettings { kUseAmoledKey: useAmoled, kThemeStyleKey: style.name, kMornyeAccentKey: mornyeAccent.name, + kUseSystemFontKey: useSystemFont, }; factory ThemeSettings.fromJson(Map json) { @@ -80,6 +86,7 @@ class ThemeSettings { useAmoled: json[kUseAmoledKey] as bool? ?? false, style: themeStyleFromString(json[kThemeStyleKey] as String?), mornyeAccent: mornyeAccentFromString(json[kMornyeAccentKey] as String?), + useSystemFont: json[kUseSystemFontKey] as bool? ?? false, ); } @@ -92,7 +99,8 @@ class ThemeSettings { other.seedColorValue == seedColorValue && other.useAmoled == useAmoled && other.style == style && - other.mornyeAccent == mornyeAccent; + other.mornyeAccent == mornyeAccent && + other.useSystemFont == useSystemFont; } @override @@ -102,7 +110,8 @@ class ThemeSettings { seedColorValue.hashCode ^ useAmoled.hashCode ^ style.hashCode ^ - mornyeAccent.hashCode; + mornyeAccent.hashCode ^ + useSystemFont.hashCode; } ThemeMode themeModeFromString(String? value) { diff --git a/lib/providers/theme_provider.dart b/lib/providers/theme_provider.dart index a6b761ee..bd205040 100644 --- a/lib/providers/theme_provider.dart +++ b/lib/providers/theme_provider.dart @@ -15,6 +15,7 @@ ThemeSettings loadBootstrapThemeSettings(SharedPreferences prefs) { useAmoled: prefs.getBool(kUseAmoledKey) ?? false, style: themeStyleFromString(prefs.getString(kThemeStyleKey)), mornyeAccent: mornyeAccentFromString(prefs.getString(kMornyeAccentKey)), + useSystemFont: prefs.getBool(kUseSystemFontKey) ?? false, ); } @@ -49,6 +50,7 @@ class ThemeNotifier extends Notifier { await prefs.setBool(kUseAmoledKey, state.useAmoled); await prefs.setString(kThemeStyleKey, state.style.name); await prefs.setString(kMornyeAccentKey, state.mornyeAccent.name); + await prefs.setBool(kUseSystemFontKey, state.useSystemFont); } catch (e) { debugPrint('Error saving theme settings: $e'); } @@ -88,4 +90,9 @@ class ThemeNotifier extends Notifier { state = state.copyWith(mornyeAccent: accent); await _saveToStorage(); } + + Future setUseSystemFont(bool value) async { + state = state.copyWith(useSystemFont: value); + await _saveToStorage(); + } } diff --git a/lib/screens/settings/appearance_settings_page.dart b/lib/screens/settings/appearance_settings_page.dart index ad82aafe..00f22afe 100644 --- a/lib/screens/settings/appearance_settings_page.dart +++ b/lib/screens/settings/appearance_settings_page.dart @@ -177,6 +177,15 @@ class AppearanceSettingsPage extends ConsumerWidget { SliverToBoxAdapter( child: SettingsGroup( children: [ + SettingsSwitchItem( + icon: Icons.font_download_outlined, + title: context.l10n.appearanceUseSystemFont, + subtitle: context.l10n.appearanceUseSystemFontSubtitle, + value: themeSettings.useSystemFont, + onChanged: ref + .read(themeProvider.notifier) + .setUseSystemFont, + ), SettingsSwitchItem( icon: Icons.animation, title: context.l10n.appearanceHeroAnimations, diff --git a/lib/screens/settings/settings_search_catalog.dart b/lib/screens/settings/settings_search_catalog.dart index 8cfcfb99..bcb3a443 100644 --- a/lib/screens/settings/settings_search_catalog.dart +++ b/lib/screens/settings/settings_search_catalog.dart @@ -114,6 +114,18 @@ class SettingsSearchCatalog { subtitle: l10n.appearanceAmoledDarkSubtitle, keywords: const ['black theme', 'oled'], ), + SettingsSearchEntry( + icon: Icons.font_download_outlined, + title: l10n.appearanceUseSystemFont, + subtitle: l10n.appearanceUseSystemFontSubtitle, + keywords: const [ + 'font', + 'typeface', + 'typography', + 'system font', + 'huruf', + ], + ), SettingsSearchEntry( icon: Icons.animation, title: l10n.appearanceHeroAnimations, diff --git a/lib/screens/upgrade_intro_screen.dart b/lib/screens/upgrade_intro_screen.dart index 015281b7..8bc9c7fb 100644 --- a/lib/screens/upgrade_intro_screen.dart +++ b/lib/screens/upgrade_intro_screen.dart @@ -325,13 +325,21 @@ class _ThemePicker extends ConsumerWidget { padding: const EdgeInsets.only(bottom: 16), child: Theme( data: style == AppThemeStyle.mornye - ? MornyeTheme.build(brightness, accent: settings.mornyeAccent) + ? MornyeTheme.build( + brightness, + accent: settings.mornyeAccent, + useSystemFont: settings.useSystemFont, + ) : brightness == Brightness.dark ? AppTheme.dark( seedColor: settings.seedColor, isAmoled: settings.useAmoled, + useSystemFont: settings.useSystemFont, ) - : AppTheme.light(seedColor: settings.seedColor), + : AppTheme.light( + seedColor: settings.seedColor, + useSystemFont: settings.useSystemFont, + ), child: _ThemeOption( style: style, selected: settings.style == style, diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index d9e0d808..002c8785 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -28,7 +28,11 @@ class AppTheme { }, ); - static ThemeData light({ColorScheme? dynamicScheme, Color? seedColor}) { + static ThemeData light({ + ColorScheme? dynamicScheme, + Color? seedColor, + bool useSystemFont = false, + }) { final scheme = dynamicScheme ?? ColorScheme.fromSeed( @@ -58,7 +62,7 @@ class AppTheme { chipTheme: _chipTheme(scheme), dividerTheme: _dividerTheme(scheme), extensions: const >[AppTokens.standard], - fontFamily: 'Google Sans Flex', + fontFamily: useSystemFont ? null : 'Google Sans Flex', ); } @@ -66,6 +70,7 @@ class AppTheme { ColorScheme? dynamicScheme, Color? seedColor, bool isAmoled = false, + bool useSystemFont = false, }) { final scheme = dynamicScheme ?? @@ -97,7 +102,7 @@ class AppTheme { chipTheme: _chipTheme(scheme), dividerTheme: _dividerTheme(scheme), extensions: const >[AppTokens.standard], - fontFamily: 'Google Sans Flex', + fontFamily: useSystemFont ? null : 'Google Sans Flex', ); } diff --git a/lib/theme/dynamic_color_wrapper.dart b/lib/theme/dynamic_color_wrapper.dart index c951ebf1..dd62c593 100644 --- a/lib/theme/dynamic_color_wrapper.dart +++ b/lib/theme/dynamic_color_wrapper.dart @@ -19,8 +19,16 @@ class DynamicColorWrapper extends ConsumerWidget { if (themeSettings.style == AppThemeStyle.mornye) { return builder( - MornyeTheme.build(Brightness.light, accent: themeSettings.mornyeAccent), - MornyeTheme.build(Brightness.dark, accent: themeSettings.mornyeAccent), + MornyeTheme.build( + Brightness.light, + accent: themeSettings.mornyeAccent, + useSystemFont: themeSettings.useSystemFont, + ), + MornyeTheme.build( + Brightness.dark, + accent: themeSettings.mornyeAccent, + useSystemFont: themeSettings.useSystemFont, + ), themeSettings.themeMode, ); } @@ -51,10 +59,14 @@ class DynamicColorWrapper extends ConsumerWidget { darkScheme = _applyAmoledColors(darkScheme); } - final lightTheme = AppTheme.light(dynamicScheme: lightScheme); + final lightTheme = AppTheme.light( + dynamicScheme: lightScheme, + useSystemFont: themeSettings.useSystemFont, + ); final darkTheme = AppTheme.dark( dynamicScheme: darkScheme, isAmoled: themeSettings.useAmoled, + useSystemFont: themeSettings.useSystemFont, ); return builder(lightTheme, darkTheme, themeSettings.themeMode); diff --git a/lib/theme/mornye_theme.dart b/lib/theme/mornye_theme.dart index 5c585fd7..2661e001 100644 --- a/lib/theme/mornye_theme.dart +++ b/lib/theme/mornye_theme.dart @@ -9,10 +9,15 @@ 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, this.accent = MornyeAccent.red}); + const MornyeTheme({ + this.chromeSurface, + this.accent = MornyeAccent.red, + this.useSystemFont = false, + }); final Color? chromeSurface; final MornyeAccent accent; + final bool useSystemFont; /// A single translucent fill for controls inside an existing glass surface. static Color controlFill(BuildContext context, {bool enabled = true}) { @@ -97,14 +102,21 @@ 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?, MornyeAccent), ThemeData>{}; + <(TargetPlatform, Brightness, Color?, MornyeAccent, bool), ThemeData>{}; static ThemeData build( Brightness brightness, { Color? chromeSurface, MornyeAccent accent = MornyeAccent.red, + bool useSystemFont = false, }) { - final key = (defaultTargetPlatform, brightness, chromeSurface, accent); + final key = ( + defaultTargetPlatform, + brightness, + chromeSurface, + accent, + useSystemFont, + ); final cached = _themeCache.remove(key); if (cached != null) { _themeCache[key] = cached; @@ -114,6 +126,7 @@ class MornyeTheme extends ThemeExtension { brightness, chromeSurface: chromeSurface, selectedAccent: accent, + useSystemFont: useSystemFont, ); if (_themeCache.length >= 16) { _themeCache.remove(_themeCache.keys.first); @@ -134,6 +147,7 @@ class MornyeTheme extends ThemeExtension { brightness ?? theme.brightness, chromeSurface: chromeSurface, accent: preferences?.accent ?? MornyeAccent.red, + useSystemFont: preferences?.useSystemFont ?? false, ); } @@ -141,6 +155,7 @@ class MornyeTheme extends ThemeExtension { Brightness brightness, { Color? chromeSurface, required MornyeAccent selectedAccent, + required bool useSystemFont, }) { final dark = brightness == Brightness.dark; final accent = accentColor(selectedAccent, brightness); @@ -179,8 +194,8 @@ class MornyeTheme extends ThemeExtension { : const Color(0xffc6c6c8), surfaceTint: Colors.transparent, ); - // Apple platforms use the installed SF system faces. Inter is bundled for - // other platforms; Cupertino's family names alone cannot provide SF there. + // Cupertino's font proxies resolve to SF on Apple and the platform fallback + // elsewhere. Inter remains the default on other platforms unless opted out. final apple = defaultTargetPlatform == TargetPlatform.iOS || defaultTargetPlatform == TargetPlatform.macOS; @@ -188,12 +203,14 @@ class MornyeTheme extends ThemeExtension { final text = type.textStyle.copyWith( inherit: true, color: foreground, - fontFamily: apple ? type.textStyle.fontFamily : 'Inter', + fontFamily: apple || useSystemFont ? type.textStyle.fontFamily : 'Inter', ); final display = type.navLargeTitleTextStyle.copyWith( inherit: true, color: foreground, - fontFamily: apple ? type.navLargeTitleTextStyle.fontFamily : 'Inter', + fontFamily: apple || useSystemFont + ? type.navLargeTitleTextStyle.fontFamily + : 'Inter', ); // Supply complete styles: replacing a Material role with a bare TextStyle // loses its system family and retains Material tracking in other roles. @@ -243,7 +260,11 @@ class MornyeTheme extends ThemeExtension { fontFamily: text.fontFamily, splashFactory: NoSplash.splashFactory, extensions: >[ - MornyeTheme(chromeSurface: chromeSurface, accent: selectedAccent), + MornyeTheme( + chromeSurface: chromeSurface, + accent: selectedAccent, + useSystemFont: useSystemFont, + ), tokens, ], ); @@ -335,16 +356,23 @@ class MornyeTheme extends ThemeExtension { } @override - MornyeTheme copyWith({Color? chromeSurface, MornyeAccent? accent}) => - MornyeTheme( - chromeSurface: chromeSurface ?? this.chromeSurface, - accent: accent ?? this.accent, - ); + MornyeTheme copyWith({ + Color? chromeSurface, + MornyeAccent? accent, + bool? useSystemFont, + }) => MornyeTheme( + chromeSurface: chromeSurface ?? this.chromeSurface, + accent: accent ?? this.accent, + useSystemFont: useSystemFont ?? this.useSystemFont, + ); @override MornyeTheme lerp(covariant MornyeTheme? other, double t) => MornyeTheme( chromeSurface: Color.lerp(chromeSurface, other?.chromeSurface, t), accent: t < 0.5 ? accent : other?.accent ?? accent, + useSystemFont: t < 0.5 + ? useSystemFont + : other?.useSystemFont ?? useSystemFont, ); } diff --git a/test/appearance_settings_test.dart b/test/appearance_settings_test.dart index 8ebc5c4a..dc8e45a7 100644 --- a/test/appearance_settings_test.dart +++ b/test/appearance_settings_test.dart @@ -94,4 +94,62 @@ void main() { expect(tester.takeException(), isNull); }); } + + for (final style in AppThemeStyle.values) { + testWidgets( + 'system font persists and restores the default ($style)', + (tester) async { + SharedPreferences.setMockInitialValues({ + kThemeStyleKey: style.name, + kMornyeAccentKey: 'blue', + kUseDynamicColorKey: false, + }); + final prefs = await SharedPreferences.getInstance(); + await openSettings(tester, prefs); + final page = find.byType(AppearanceSettingsPage); + final original = Theme.of( + tester.element(page), + ).textTheme.bodyLarge!.fontFamily; + final setting = find.text('Use system font'); + await tester.ensureVisible(setting); + await tester.pumpAndSettle(); + await tester.tap(setting); + await tester.pumpAndSettle(); + final theme = Theme.of(tester.element(page)); + final systemFamily = theme.textTheme.bodyLarge!.fontFamily; + expect(systemFamily, isNot('Inter')); + expect(systemFamily, isNot('Google Sans Flex')); + expect(prefs.getBool(kUseSystemFontKey), isTrue); + if (style == AppThemeStyle.mornye) { + final overlay = MornyeTheme.fromContext( + tester.element(page), + brightness: Brightness.dark, + ); + expect(overlay.extension()!.useSystemFont, isTrue); + expect(overlay.extension()!.accent, MornyeAccent.blue); + expect(overlay.textTheme.bodyLarge!.fontFamily, systemFamily); + } + await tester.pumpWidget(const SizedBox()); + await openSettings(tester, prefs); + expect( + Theme.of(tester.element(page)).textTheme.bodyLarge!.fontFamily, + systemFamily, + ); + await tester.ensureVisible(setting); + await tester.pumpAndSettle(); + await tester.tap(setting); + await tester.pumpAndSettle(); + expect(prefs.getBool(kUseSystemFontKey), isFalse); + expect( + Theme.of(tester.element(page)).textTheme.bodyLarge!.fontFamily, + original, + ); + expect(tester.takeException(), isNull); + }, + variant: const TargetPlatformVariant({ + TargetPlatform.android, + TargetPlatform.iOS, + }), + ); + } } diff --git a/test/models_and_utils_test.dart b/test/models_and_utils_test.dart index ed5b10fa..4594aca0 100644 --- a/test/models_and_utils_test.dart +++ b/test/models_and_utils_test.dart @@ -1109,6 +1109,7 @@ void main() { useAmoled: true, style: AppThemeStyle.mornye, mornyeAccent: MornyeAccent.blue, + useSystemFont: true, ); final decoded = ThemeSettings.fromJson(settings.toJson()); @@ -1122,6 +1123,9 @@ void main() { expect(decoded.style, AppThemeStyle.mornye); expect(decoded.mornyeAccent, MornyeAccent.blue); expect(copied.mornyeAccent, MornyeAccent.blue); + expect(decoded.useSystemFont, isTrue); + expect(copied.useSystemFont, isTrue); + expect(ThemeSettings.fromJson({}).useSystemFont, isFalse); expect(ThemeSettings.fromJson({}).mornyeAccent, MornyeAccent.red); expect( ThemeSettings.fromJson({kMornyeAccentKey: 'unknown'}).mornyeAccent,