mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 23:11:45 +02:00
feat(appearance): support imported TTF and OTF fonts
This commit is contained in:
1 parent
cdceda4585
commit
b30dd9fed0
12 files changed
+362
-7
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<String?>((ref) {
|
||||
final id = ref.watch(themeProvider.select((theme) => theme.customFontId));
|
||||
return CustomFontService.instance.restore(id);
|
||||
});
|
||||
@@ -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<ThemeSettings> {
|
||||
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<ThemeSettings> {
|
||||
await _saveToStorage();
|
||||
}
|
||||
|
||||
Future<void> setCustomFont(String id, String name) async {
|
||||
_editState(
|
||||
(current) => current.copyWith(customFontId: id, customFontName: name),
|
||||
);
|
||||
await _saveToStorage();
|
||||
}
|
||||
|
||||
Future<void> setMornyeGlassClarity(
|
||||
double value, {
|
||||
bool persist = true,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Directory> Function()? directory,
|
||||
Future<void> Function(String, Uint8List)? loadFont,
|
||||
}) : _directory = directory ?? _fontDirectory,
|
||||
_loadFont = loadFont ?? _register;
|
||||
|
||||
static const maxBytes = 10 * 1024 * 1024;
|
||||
static final instance = CustomFontService();
|
||||
final Future<Directory> Function() _directory;
|
||||
final Future<void> Function(String, Uint8List) _loadFont;
|
||||
final Map<String, Future<String>> _loads = {};
|
||||
static final _idPattern = RegExp(r'^[a-f0-9]{64}$');
|
||||
|
||||
static Future<Directory> _fontDirectory() async => Directory(
|
||||
'${(await getApplicationSupportDirectory()).path}/custom_fonts',
|
||||
).create(recursive: true);
|
||||
|
||||
static Future<void> _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<String> 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<String> _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<String?> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <ThemeExtension<dynamic>>[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 <ThemeExtension<dynamic>>[AppTokens.standard],
|
||||
fontFamily: useSystemFont ? null : 'Google Sans Flex',
|
||||
fontFamily: fontFamily ?? (useSystemFont ? null : 'Google Sans Flex'),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -13,6 +13,7 @@ class MornyeTheme extends ThemeExtension<MornyeTheme> {
|
||||
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<MornyeTheme> {
|
||||
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<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?, 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<MornyeTheme> {
|
||||
chromeSurface,
|
||||
accent,
|
||||
useSystemFont,
|
||||
fontFamily,
|
||||
);
|
||||
final cached = _themeCache.remove(key);
|
||||
if (cached != null) {
|
||||
@@ -145,6 +152,7 @@ class MornyeTheme extends ThemeExtension<MornyeTheme> {
|
||||
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<MornyeTheme> {
|
||||
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<MornyeTheme> {
|
||||
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<MornyeTheme> {
|
||||
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<MornyeTheme> {
|
||||
chromeSurface: chromeSurface,
|
||||
accent: selectedAccent,
|
||||
useSystemFont: useSystemFont,
|
||||
fontFamily: fontFamily,
|
||||
),
|
||||
tokens,
|
||||
],
|
||||
@@ -408,12 +423,14 @@ class MornyeTheme extends ThemeExtension<MornyeTheme> {
|
||||
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> {
|
||||
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,
|
||||
|
||||
@@ -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<CustomFontSetting> createState() => _CustomFontSettingState();
|
||||
}
|
||||
|
||||
class _CustomFontSettingState extends ConsumerState<CustomFontSetting> {
|
||||
bool _importing = false;
|
||||
|
||||
Future<void> _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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user