diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index c9b2ddb4..5051fbd7 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -7152,6 +7152,10 @@ "playbackPauseOnMuteDescription": "Pause at zero media volume and resume when raised. Manual pauses stay paused. Standard audio output only.", "playbackOnHeadphonesConnected": "Play when headphones connect", "playbackOnHeadphonesConnectedDescription": "Resume the current song when wired or Bluetooth headphones connect. Disconnecting pauses playback. Built-in player only.", + "appearanceCustomFont": "Custom font", + "appearanceCustomFontSubtitle": "Import a TTF or OTF file (up to 10 MB). Overrides the theme and system font.", + "appearanceCustomFontInvalid": "Could not load this font. Choose a valid TTF or OTF file up to 10 MB.", + "appearanceCustomFontReset": "Use theme font", "optionsEmbeddedCoverSizeOriginal": "Original resolution", "@optionsEmbeddedCoverSizeOriginal": { "description": "Option that preserves the provider artwork at its original resolution" diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index cfb721aa..b134086f 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -7165,6 +7165,10 @@ "artistUpcomingConcerts": "Konser Mendatang", "artistAllUpcomingConcerts": "Semua Konser Mendatang", "artistNoUpcomingConcerts": "Tiada konser mendatang.", + "appearanceCustomFont": "Font kustom", + "appearanceCustomFontSubtitle": "Impor file TTF atau OTF (maksimal 10 MB). Menggantikan font tema dan sistem.", + "appearanceCustomFontInvalid": "Font tidak dapat dimuat. Pilih file TTF atau OTF yang valid, maksimal 10 MB.", + "appearanceCustomFontReset": "Gunakan font tema", "optionsEmbeddedCoverSizeOriginal": "Resolusi asli", "@optionsEmbeddedCoverSizeOriginal": { "description": "Option that preserves the provider artwork at its original resolution" diff --git a/lib/models/theme_settings.dart b/lib/models/theme_settings.dart index 9baad713..9970bacd 100644 --- a/lib/models/theme_settings.dart +++ b/lib/models/theme_settings.dart @@ -7,6 +7,8 @@ const String kUseAmoledKey = 'use_amoled'; const String kThemeStyleKey = 'theme_style'; const String kMornyeAccentKey = 'mornye_accent'; const String kUseSystemFontKey = 'use_system_font'; +const String kCustomFontIdKey = 'custom_font_id'; +const String kCustomFontNameKey = 'custom_font_name'; const String kMornyeGlassClarityKey = 'mornye_glass_clarity'; const String kMornyeGlassOutlinesKey = 'mornye_glass_outlines'; const double kDefaultMornyeGlassClarity = 0.75; @@ -43,6 +45,8 @@ class ThemeSettings { final AppThemeStyle style; final MornyeAccent mornyeAccent; final bool useSystemFont; + final String customFontId; + final String customFontName; final double mornyeGlassClarity; final bool mornyeGlassOutlines; @@ -54,6 +58,8 @@ class ThemeSettings { this.style = AppThemeStyle.material, this.mornyeAccent = MornyeAccent.red, this.useSystemFont = false, + this.customFontId = '', + this.customFontName = '', this.mornyeGlassClarity = kDefaultMornyeGlassClarity, this.mornyeGlassOutlines = true, }); @@ -68,6 +74,8 @@ class ThemeSettings { AppThemeStyle? style, MornyeAccent? mornyeAccent, bool? useSystemFont, + String? customFontId, + String? customFontName, double? mornyeGlassClarity, bool? mornyeGlassOutlines, }) { @@ -79,6 +87,8 @@ class ThemeSettings { style: style ?? this.style, mornyeAccent: mornyeAccent ?? this.mornyeAccent, useSystemFont: useSystemFont ?? this.useSystemFont, + customFontId: customFontId ?? this.customFontId, + customFontName: customFontName ?? this.customFontName, mornyeGlassClarity: normalizeMornyeGlassClarity( mornyeGlassClarity ?? this.mornyeGlassClarity, ), @@ -94,6 +104,8 @@ class ThemeSettings { kThemeStyleKey: style.name, kMornyeAccentKey: mornyeAccent.name, kUseSystemFontKey: useSystemFont, + kCustomFontIdKey: customFontId, + kCustomFontNameKey: customFontName, kMornyeGlassClarityKey: mornyeGlassClarity, kMornyeGlassOutlinesKey: mornyeGlassOutlines, }; @@ -107,6 +119,8 @@ class ThemeSettings { style: themeStyleFromString(json[kThemeStyleKey] as String?), mornyeAccent: mornyeAccentFromString(json[kMornyeAccentKey] as String?), useSystemFont: json[kUseSystemFontKey] as bool? ?? false, + customFontId: json[kCustomFontIdKey] as String? ?? '', + customFontName: json[kCustomFontNameKey] as String? ?? '', mornyeGlassClarity: normalizeMornyeGlassClarity( json[kMornyeGlassClarityKey] as num?, ), @@ -125,6 +139,8 @@ class ThemeSettings { other.style == style && other.mornyeAccent == mornyeAccent && other.useSystemFont == useSystemFont && + other.customFontId == customFontId && + other.customFontName == customFontName && other.mornyeGlassClarity == mornyeGlassClarity && other.mornyeGlassOutlines == mornyeGlassOutlines; } @@ -138,6 +154,8 @@ class ThemeSettings { style.hashCode ^ mornyeAccent.hashCode ^ useSystemFont.hashCode ^ + customFontId.hashCode ^ + customFontName.hashCode ^ mornyeGlassClarity.hashCode ^ mornyeGlassOutlines.hashCode; } diff --git a/lib/providers/custom_font_provider.dart b/lib/providers/custom_font_provider.dart new file mode 100644 index 00000000..869b3646 --- /dev/null +++ b/lib/providers/custom_font_provider.dart @@ -0,0 +1,8 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/providers/theme_provider.dart'; +import 'package:spotiflac_android/services/custom_font_service.dart'; + +final customFontFamilyProvider = FutureProvider((ref) { + final id = ref.watch(themeProvider.select((theme) => theme.customFontId)); + return CustomFontService.instance.restore(id); +}); diff --git a/lib/providers/theme_provider.dart b/lib/providers/theme_provider.dart index f867c477..76c41eaa 100644 --- a/lib/providers/theme_provider.dart +++ b/lib/providers/theme_provider.dart @@ -16,6 +16,8 @@ ThemeSettings loadBootstrapThemeSettings(SharedPreferences prefs) { style: themeStyleFromString(prefs.getString(kThemeStyleKey)), mornyeAccent: mornyeAccentFromString(prefs.getString(kMornyeAccentKey)), useSystemFont: prefs.getBool(kUseSystemFontKey) ?? false, + customFontId: prefs.getString(kCustomFontIdKey) ?? '', + customFontName: prefs.getString(kCustomFontNameKey) ?? '', mornyeGlassClarity: normalizeMornyeGlassClarity( prefs.getDouble(kMornyeGlassClarityKey), ), @@ -77,6 +79,8 @@ class ThemeNotifier extends Notifier { await prefs.setString(kThemeStyleKey, snapshot.style.name); await prefs.setString(kMornyeAccentKey, snapshot.mornyeAccent.name); await prefs.setBool(kUseSystemFontKey, snapshot.useSystemFont); + await prefs.setString(kCustomFontIdKey, snapshot.customFontId); + await prefs.setString(kCustomFontNameKey, snapshot.customFontName); await prefs.setDouble( kMornyeGlassClarityKey, snapshot.mornyeGlassClarity, @@ -125,6 +129,13 @@ class ThemeNotifier extends Notifier { await _saveToStorage(); } + Future setCustomFont(String id, String name) async { + _editState( + (current) => current.copyWith(customFontId: id, customFontName: name), + ); + await _saveToStorage(); + } + Future setMornyeGlassClarity( double value, { bool persist = true, diff --git a/lib/screens/settings/appearance_settings_page.dart b/lib/screens/settings/appearance_settings_page.dart index 97791180..1d64debc 100644 --- a/lib/screens/settings/appearance_settings_page.dart +++ b/lib/screens/settings/appearance_settings_page.dart @@ -12,6 +12,7 @@ import 'package:spotiflac_android/widgets/mornye_chrome.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/app_sliver_header.dart'; +import 'package:spotiflac_android/widgets/custom_font_setting.dart'; class AppearanceSettingsPage extends ConsumerWidget { const AppearanceSettingsPage({super.key}); @@ -208,6 +209,7 @@ class AppearanceSettingsPage extends ConsumerWidget { .read(settingsProvider.notifier) .setHeroAnimationsEnabled(value), ), + const CustomFontSetting(), SettingsSwitchItem( icon: Icons.blur_on, title: context.l10n.appearanceForceBlur, diff --git a/lib/services/custom_font_service.dart b/lib/services/custom_font_service.dart new file mode 100644 index 00000000..7f213221 --- /dev/null +++ b/lib/services/custom_font_service.dart @@ -0,0 +1,96 @@ +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:flutter/services.dart'; +import 'package:path_provider/path_provider.dart'; + +/// Copies imported fonts into app storage; never persists a picker/container path. +class CustomFontService { + CustomFontService({ + Future Function()? directory, + Future Function(String, Uint8List)? loadFont, + }) : _directory = directory ?? _fontDirectory, + _loadFont = loadFont ?? _register; + + static const maxBytes = 10 * 1024 * 1024; + static final instance = CustomFontService(); + final Future Function() _directory; + final Future Function(String, Uint8List) _loadFont; + final Map> _loads = {}; + static final _idPattern = RegExp(r'^[a-f0-9]{64}$'); + + static Future _fontDirectory() async => Directory( + '${(await getApplicationSupportDirectory()).path}/custom_fonts', + ).create(recursive: true); + + static Future _register(String family, Uint8List bytes) async { + final loader = FontLoader(family) + ..addFont(Future.value(ByteData.sublistView(bytes))); + await loader.load(); + } + + static void validate(Uint8List bytes) { + if (bytes.length < 12 || bytes.length > maxBytes) { + throw const FormatException('Invalid font size'); + } + final data = ByteData.sublistView(bytes); + final signature = data.getUint32(0); + if (signature != 0x00010000 && signature != 0x4f54544f) { + throw const FormatException('Expected a TTF or OTF font'); + } + final count = data.getUint16(4); + if (count == 0 || 12 + count * 16 > bytes.length) { + throw const FormatException('Invalid font table directory'); + } + for (var i = 0; i < count; i++) { + final table = 12 + i * 16; + final offset = data.getUint32(table + 8); + final length = data.getUint32(table + 12); + if (offset > bytes.length || length > bytes.length - offset) { + throw const FormatException('Truncated font table'); + } + } + } + + Future import(Uint8List bytes) async { + validate(bytes); + final id = sha256.convert(bytes).toString(); + await _load(id, bytes); + final directory = await _directory(); + final destination = File('${directory.path}/$id.font'); + if (!await destination.exists()) { + final staged = await File( + '${directory.path}/$id.tmp', + ).writeAsBytes(bytes, flush: true); + await staged.rename(destination.path); + } + return id; + } + + Future _load(String id, Uint8List bytes) => + _loads.putIfAbsent(id, () async { + final family = 'ImportedFont_$id'; + try { + await _loadFont(family, bytes); + return family; + } catch (_) { + _loads.remove(id); + rethrow; + } + }); + + Future restore(String id) async { + if (!_idPattern.hasMatch(id)) return null; + try { + final file = File('${(await _directory()).path}/$id.font'); + if (await file.length() > maxBytes) return null; + final bytes = await file.readAsBytes(); + validate(bytes); + if (sha256.convert(bytes).toString() != id) return null; + return await _load(id, bytes); + } catch (_) { + // Missing fonts after restoring preferences fall back to the theme font. + return null; + } + } +} diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index dc90f2ba..9747dad7 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -31,6 +31,7 @@ class AppTheme { ColorScheme? dynamicScheme, Color? seedColor, bool useSystemFont = false, + String? fontFamily, }) { final scheme = dynamicScheme ?? @@ -61,7 +62,7 @@ class AppTheme { chipTheme: _chipTheme(scheme), dividerTheme: _dividerTheme(scheme), extensions: const >[AppTokens.standard], - fontFamily: useSystemFont ? null : 'Google Sans Flex', + fontFamily: fontFamily ?? (useSystemFont ? null : 'Google Sans Flex'), ); } @@ -70,6 +71,7 @@ class AppTheme { Color? seedColor, bool isAmoled = false, bool useSystemFont = false, + String? fontFamily, }) { final scheme = dynamicScheme ?? @@ -101,7 +103,7 @@ class AppTheme { chipTheme: _chipTheme(scheme), dividerTheme: _dividerTheme(scheme), extensions: const >[AppTokens.standard], - fontFamily: useSystemFont ? null : 'Google Sans Flex', + fontFamily: fontFamily ?? (useSystemFont ? null : 'Google Sans Flex'), ); } diff --git a/lib/theme/dynamic_color_wrapper.dart b/lib/theme/dynamic_color_wrapper.dart index ae381f2d..14d48ff0 100644 --- a/lib/theme/dynamic_color_wrapper.dart +++ b/lib/theme/dynamic_color_wrapper.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:dynamic_color/dynamic_color.dart'; import 'package:material_ui/material_ui.dart' as material_ui; import 'package:spotiflac_android/providers/theme_provider.dart'; +import 'package:spotiflac_android/providers/custom_font_provider.dart'; import 'package:spotiflac_android/theme/app_theme.dart'; import 'package:spotiflac_android/models/theme_settings.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; @@ -16,6 +17,7 @@ class DynamicColorWrapper extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final themeSettings = ref.watch(themeProvider); + final fontFamily = ref.watch(customFontFamilyProvider).asData?.value; if (themeSettings.style == AppThemeStyle.mornye) { return builder( @@ -23,6 +25,7 @@ class DynamicColorWrapper extends ConsumerWidget { Brightness.light, accent: themeSettings.mornyeAccent, useSystemFont: themeSettings.useSystemFont, + fontFamily: fontFamily, glassClarity: themeSettings.mornyeGlassClarity, glassOutlines: themeSettings.mornyeGlassOutlines, ), @@ -30,6 +33,7 @@ class DynamicColorWrapper extends ConsumerWidget { Brightness.dark, accent: themeSettings.mornyeAccent, useSystemFont: themeSettings.useSystemFont, + fontFamily: fontFamily, glassClarity: themeSettings.mornyeGlassClarity, glassOutlines: themeSettings.mornyeGlassOutlines, ), @@ -66,11 +70,13 @@ class DynamicColorWrapper extends ConsumerWidget { final lightTheme = AppTheme.light( dynamicScheme: lightScheme, useSystemFont: themeSettings.useSystemFont, + fontFamily: fontFamily, ); final darkTheme = AppTheme.dark( dynamicScheme: darkScheme, isAmoled: themeSettings.useAmoled, useSystemFont: themeSettings.useSystemFont, + fontFamily: fontFamily, ); return builder(lightTheme, darkTheme, themeSettings.themeMode); diff --git a/lib/theme/mornye_theme.dart b/lib/theme/mornye_theme.dart index 5867db03..a0125319 100644 --- a/lib/theme/mornye_theme.dart +++ b/lib/theme/mornye_theme.dart @@ -13,6 +13,7 @@ class MornyeTheme extends ThemeExtension { this.chromeSurface, this.accent = MornyeAccent.red, this.useSystemFont = false, + this.fontFamily, this.glassClarity = kDefaultMornyeGlassClarity, this.glassOutlines = true, }); @@ -20,6 +21,7 @@ class MornyeTheme extends ThemeExtension { final Color? chromeSurface; final MornyeAccent accent; final bool useSystemFont; + final String? fontFamily; final double glassClarity; final bool glassOutlines; @@ -118,13 +120,17 @@ 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, bool), ThemeData>{}; + < + (TargetPlatform, Brightness, Color?, MornyeAccent, bool, String?), + ThemeData + >{}; static ThemeData build( Brightness brightness, { Color? chromeSurface, MornyeAccent accent = MornyeAccent.red, bool useSystemFont = false, + String? fontFamily, double glassClarity = kDefaultMornyeGlassClarity, bool glassOutlines = true, }) { @@ -134,6 +140,7 @@ class MornyeTheme extends ThemeExtension { chromeSurface, accent, useSystemFont, + fontFamily, ); final cached = _themeCache.remove(key); if (cached != null) { @@ -145,6 +152,7 @@ class MornyeTheme extends ThemeExtension { chromeSurface: chromeSurface, selectedAccent: accent, useSystemFont: useSystemFont, + fontFamily: fontFamily, ); if (_themeCache.length >= 16) { _themeCache.remove(_themeCache.keys.first); @@ -190,6 +198,7 @@ class MornyeTheme extends ThemeExtension { chromeSurface: chromeSurface, accent: preferences?.accent ?? MornyeAccent.red, useSystemFont: preferences?.useSystemFont ?? false, + fontFamily: preferences?.fontFamily, glassClarity: preferences?.glassClarity ?? kDefaultMornyeGlassClarity, glassOutlines: preferences?.glassOutlines ?? true, ); @@ -200,6 +209,7 @@ class MornyeTheme extends ThemeExtension { Color? chromeSurface, required MornyeAccent selectedAccent, required bool useSystemFont, + String? fontFamily, }) { final dark = brightness == Brightness.dark; final accent = accentColor(selectedAccent, brightness); @@ -251,14 +261,18 @@ class MornyeTheme extends ThemeExtension { final text = type.textStyle.copyWith( inherit: true, color: foreground, - fontFamily: apple || useSystemFont ? type.textStyle.fontFamily : 'Inter', + fontFamily: + fontFamily ?? + (apple || useSystemFont ? type.textStyle.fontFamily : 'Inter'), ); final display = type.navLargeTitleTextStyle.copyWith( inherit: true, color: foreground, - fontFamily: apple || useSystemFont - ? type.navLargeTitleTextStyle.fontFamily - : 'Inter', + fontFamily: + 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. @@ -312,6 +326,7 @@ class MornyeTheme extends ThemeExtension { chromeSurface: chromeSurface, accent: selectedAccent, useSystemFont: useSystemFont, + fontFamily: fontFamily, ), tokens, ], @@ -408,12 +423,14 @@ class MornyeTheme extends ThemeExtension { Color? chromeSurface, MornyeAccent? accent, bool? useSystemFont, + String? fontFamily, double? glassClarity, bool? glassOutlines, }) => MornyeTheme( chromeSurface: chromeSurface ?? this.chromeSurface, accent: accent ?? this.accent, useSystemFont: useSystemFont ?? this.useSystemFont, + fontFamily: fontFamily ?? this.fontFamily, glassClarity: glassClarity ?? this.glassClarity, glassOutlines: glassOutlines ?? this.glassOutlines, ); @@ -422,6 +439,7 @@ class MornyeTheme extends ThemeExtension { MornyeTheme lerp(covariant MornyeTheme? other, double t) => MornyeTheme( chromeSurface: Color.lerp(chromeSurface, other?.chromeSurface, t), accent: t < 0.5 ? accent : other?.accent ?? accent, + fontFamily: t < 0.5 ? fontFamily : other?.fontFamily, useSystemFont: t < 0.5 ? useSystemFont : other?.useSystemFont ?? useSystemFont, diff --git a/lib/widgets/custom_font_setting.dart b/lib/widgets/custom_font_setting.dart new file mode 100644 index 00000000..047ebe3f --- /dev/null +++ b/lib/widgets/custom_font_setting.dart @@ -0,0 +1,72 @@ +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/providers/theme_provider.dart'; +import 'package:spotiflac_android/services/custom_font_service.dart'; + +class CustomFontSetting extends ConsumerStatefulWidget { + const CustomFontSetting({super.key}); + + @override + ConsumerState createState() => _CustomFontSettingState(); +} + +class _CustomFontSettingState extends ConsumerState { + bool _importing = false; + + Future _import() async { + setState(() => _importing = true); + try { + final file = await FilePicker.pickFile( + type: FileType.custom, + allowedExtensions: ['ttf', 'otf'], + ); + if (file == null || !mounted) return; + if ((await file.length() ?? 0) > CustomFontService.maxBytes) { + throw const FormatException('Font exceeds import limit'); + } + final id = await CustomFontService.instance.import( + await file.readAsBytes(), + ); + if (!mounted) return; + await ref.read(themeProvider.notifier).setCustomFont(id, file.name); + } catch (_) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.appearanceCustomFontInvalid)), + ); + } + } finally { + if (mounted) setState(() => _importing = false); + } + } + + @override + Widget build(BuildContext context) { + final theme = ref.watch(themeProvider); + return ListTile( + leading: const Icon(Icons.text_fields), + title: Text(context.l10n.appearanceCustomFont), + subtitle: Text( + theme.customFontId.isEmpty + ? context.l10n.appearanceCustomFontSubtitle + : theme.customFontName, + ), + onTap: _importing ? null : _import, + trailing: _importing + ? const SizedBox.square( + dimension: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : theme.customFontId.isEmpty + ? const Icon(Icons.add) + : IconButton( + tooltip: context.l10n.appearanceCustomFontReset, + onPressed: () => + ref.read(themeProvider.notifier).setCustomFont('', ''), + icon: const Icon(Icons.close), + ), + ); + } +} diff --git a/test/custom_font_test.dart b/test/custom_font_test.dart new file mode 100644 index 00000000..5591dffd --- /dev/null +++ b/test/custom_font_test.dart @@ -0,0 +1,114 @@ +import 'dart:io'; +import 'dart:typed_data'; + +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/models/theme_settings.dart'; +import 'package:spotiflac_android/providers/theme_provider.dart'; +import 'package:spotiflac_android/services/custom_font_service.dart'; +import 'package:spotiflac_android/theme/app_theme.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test( + 'import persists a private copy, restores after restart, rejects damage', + () async { + final directory = await Directory.systemTemp.createTemp('font-import-'); + addTearDown(() => directory.delete(recursive: true)); + final bytes = await File('assets/fonts/InterVariable.ttf').readAsBytes(); + var loads = 0; + CustomFontService service() => CustomFontService( + directory: () async => directory, + loadFont: (_, data) async { + loads++; + expect(data, bytes); + }, + ); + final importer = service(); + final id = await importer.import(bytes); + expect(await importer.restore(id), 'ImportedFont_$id'); + expect(loads, 1); + expect(await service().restore(id), 'ImportedFont_$id'); + expect(loads, 2); + await File('${directory.path}/$id.font').writeAsBytes([1, 2]); + expect(await service().restore(id), isNull); + expect(await service().restore('../outside'), isNull); + expect( + () => CustomFontService.validate(Uint8List(12)), + throwsFormatException, + ); + expect( + () => CustomFontService.validate(bytes.sublist(0, 32)), + throwsFormatException, + ); + }, + ); + + test('font choice survives bootstrap, export and reset', () async { + SharedPreferences.setMockInitialValues({kUseSystemFontKey: true}); + final container = ProviderContainer(); + addTearDown(container.dispose); + final notifier = container.read(themeProvider.notifier); + await notifier.setCustomFont('selected-font', 'My font.ttf'); + final prefs = await SharedPreferences.getInstance(); + final theme = loadBootstrapThemeSettings(prefs); + expect(theme.customFontId, 'selected-font'); + expect(theme.customFontName, 'My font.ttf'); + expect(ThemeSettings.fromJson(theme.toJson()), theme); + await notifier.setCustomFont('', ''); + expect(loadBootstrapThemeSettings(prefs).customFontId, isEmpty); + expect(loadBootstrapThemeSettings(prefs).useSystemFont, isTrue); + }); + + testWidgets( + 'custom typography survives Mornye local surface reconstruction', + (tester) async { + final theme = MornyeTheme.build( + Brightness.dark, + fontFamily: 'ImportedFont_test', + ); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Builder( + builder: (context) { + final surface = MornyeTheme.fromContext( + context, + chromeSurface: Colors.red, + ); + expect( + surface.textTheme.bodyLarge!.fontFamily, + 'ImportedFont_test', + ); + expect( + surface.textTheme.headlineSmall!.fontFamily, + 'ImportedFont_test', + ); + return const SizedBox(); + }, + ), + ), + ); + expect( + AppTheme.light( + fontFamily: 'ImportedFont_test', + ).textTheme.bodyMedium!.fontFamily, + 'ImportedFont_test', + ); + expect( + AppTheme.dark( + fontFamily: 'ImportedFont_test', + ).textTheme.bodyMedium!.fontFamily, + 'ImportedFont_test', + ); + expect( + MornyeTheme.build(Brightness.dark).textTheme.bodyLarge!.fontFamily, + isNot('ImportedFont_test'), + ); + }, + ); +}