feat(customization): system font, custom Google Fonts by URL, font size slider
This commit is contained in:
@@ -1,37 +1,120 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class AppFont {
|
||||
final String id;
|
||||
final String label;
|
||||
final String googleFamily;
|
||||
final String? googleFamily;
|
||||
|
||||
const AppFont({
|
||||
required this.id,
|
||||
required this.label,
|
||||
required this.googleFamily,
|
||||
this.googleFamily,
|
||||
});
|
||||
|
||||
bool get isSystem => googleFamily == null;
|
||||
bool get isCustom => id.startsWith(AppFonts.customPrefix);
|
||||
}
|
||||
|
||||
class AppFonts {
|
||||
static const String prefKey = 'app_font';
|
||||
static const String scalePrefKey = 'app_font_scale';
|
||||
static const String customPrefKey = 'app_custom_fonts';
|
||||
static const String customPrefix = 'g:';
|
||||
|
||||
static const List<AppFont> all = [
|
||||
static const double minScale = 0.85;
|
||||
static const double maxScale = 1.35;
|
||||
static const double defaultScale = 1.0;
|
||||
|
||||
static const List<AppFont> builtIn = [
|
||||
AppFont(id: 'system', label: 'Системный'),
|
||||
AppFont(id: 'inter', label: 'Inter', googleFamily: 'Inter'),
|
||||
AppFont(id: 'unbounded', label: 'Unbounded', googleFamily: 'Unbounded'),
|
||||
];
|
||||
|
||||
static AppFont get fallback => all.first;
|
||||
static AppFont get fallback => builtIn.first;
|
||||
|
||||
static AppFont byId(String? id) {
|
||||
return all.firstWhere((f) => f.id == id, orElse: () => fallback);
|
||||
static String customId(String family) => '$customPrefix$family';
|
||||
|
||||
static AppFont resolve(String id) {
|
||||
if (id.startsWith(customPrefix)) {
|
||||
final family = id.substring(customPrefix.length);
|
||||
return AppFont(id: id, label: family, googleFamily: family);
|
||||
}
|
||||
return builtIn.firstWhere((f) => f.id == id, orElse: () => fallback);
|
||||
}
|
||||
|
||||
static TextTheme textTheme(String id, TextTheme base) {
|
||||
return GoogleFonts.getTextTheme(byId(id).googleFamily, base);
|
||||
final family = resolve(id).googleFamily;
|
||||
if (family == null) return base;
|
||||
try {
|
||||
return GoogleFonts.getTextTheme(family, base);
|
||||
} catch (_) {
|
||||
return base;
|
||||
}
|
||||
}
|
||||
|
||||
static TextStyle sample(String id, {required double fontSize}) {
|
||||
return GoogleFonts.getFont(byId(id).googleFamily, fontSize: fontSize);
|
||||
final family = resolve(id).googleFamily;
|
||||
if (family == null) return TextStyle(fontSize: fontSize);
|
||||
try {
|
||||
return GoogleFonts.getFont(family, fontSize: fontSize);
|
||||
} catch (_) {
|
||||
return TextStyle(fontSize: fontSize);
|
||||
}
|
||||
}
|
||||
|
||||
static double clampScale(double scale) =>
|
||||
scale.clamp(minScale, maxScale).toDouble();
|
||||
|
||||
static String? familyFromInput(String input) {
|
||||
var value = input.trim();
|
||||
if (value.isEmpty) return null;
|
||||
|
||||
final uri = Uri.tryParse(value);
|
||||
if (uri != null && uri.host.contains('fonts.google.com')) {
|
||||
final idx = uri.pathSegments.indexOf('specimen');
|
||||
if (idx != -1 && idx + 1 < uri.pathSegments.length) {
|
||||
value = uri.pathSegments[idx + 1];
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
value = Uri.decodeComponent(value);
|
||||
} catch (_) {}
|
||||
value = value.replaceAll('+', ' ').trim();
|
||||
return value.isEmpty ? null : value;
|
||||
}
|
||||
|
||||
static String? matchGoogleFamily(String family) {
|
||||
final map = GoogleFonts.asMap();
|
||||
if (map.containsKey(family)) return family;
|
||||
final lower = family.toLowerCase();
|
||||
for (final key in map.keys) {
|
||||
if (key.toLowerCase() == lower) return key;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<List<String>> loadCustomFamilies() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getStringList(customPrefKey) ?? const <String>[];
|
||||
}
|
||||
|
||||
static Future<void> addCustomFamily(String family) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final list = prefs.getStringList(customPrefKey) ?? <String>[];
|
||||
if (!list.contains(family)) {
|
||||
list.add(family);
|
||||
await prefs.setStringList(customPrefKey, list);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> removeCustomFamily(String family) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final list = prefs.getStringList(customPrefKey) ?? <String>[];
|
||||
list.remove(family);
|
||||
await prefs.setStringList(customPrefKey, list);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../../../core/config/app_fonts.dart';
|
||||
import '../../../core/utils/haptics.dart';
|
||||
import '../../../main.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
|
||||
class CustomizationScreen extends StatefulWidget {
|
||||
const CustomizationScreen({super.key});
|
||||
@@ -14,6 +15,19 @@ class CustomizationScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _CustomizationScreenState extends State<CustomizationScreen> {
|
||||
List<String> _custom = const [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_reloadCustom();
|
||||
}
|
||||
|
||||
Future<void> _reloadCustom() async {
|
||||
final list = await AppFonts.loadCustomFamilies();
|
||||
if (mounted) setState(() => _custom = list);
|
||||
}
|
||||
|
||||
void _selectFont(String id) {
|
||||
final app = KometApp.stateOf(context);
|
||||
if (app == null || app.fontId == id) return;
|
||||
@@ -21,10 +35,107 @@ class _CustomizationScreenState extends State<CustomizationScreen> {
|
||||
app.applyAppFont(id);
|
||||
}
|
||||
|
||||
Future<void> _addFont(String raw) async {
|
||||
final parsed = AppFonts.familyFromInput(raw);
|
||||
if (parsed == null) {
|
||||
if (mounted) {
|
||||
showCustomNotification(context, 'Введите ссылку или название шрифта');
|
||||
}
|
||||
return;
|
||||
}
|
||||
final canonical = AppFonts.matchGoogleFamily(parsed);
|
||||
if (canonical == null) {
|
||||
if (mounted) {
|
||||
showCustomNotification(
|
||||
context,
|
||||
'Шрифт «$parsed» не найден в Google Fonts',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
await AppFonts.addCustomFamily(canonical);
|
||||
await _reloadCustom();
|
||||
if (!mounted) return;
|
||||
KometApp.stateOf(context)?.applyAppFont(AppFonts.customId(canonical));
|
||||
Haptics.success();
|
||||
showCustomNotification(context, 'Шрифт «$canonical» добавлен');
|
||||
}
|
||||
|
||||
Future<void> _removeFont(String family) async {
|
||||
await AppFonts.removeCustomFamily(family);
|
||||
await _reloadCustom();
|
||||
if (!mounted) return;
|
||||
final app = KometApp.stateOf(context);
|
||||
if (app != null && app.fontId == AppFonts.customId(family)) {
|
||||
app.applyAppFont(AppFonts.fallback.id);
|
||||
}
|
||||
showCustomNotification(context, 'Шрифт «$family» удалён');
|
||||
}
|
||||
|
||||
Future<void> _showAddFontDialog() async {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final controller = TextEditingController();
|
||||
final result = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (ctx) {
|
||||
return AlertDialog(
|
||||
backgroundColor: cs.surfaceContainerHigh,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
),
|
||||
title: const Text('Добавить шрифт'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Вставьте ссылку Google Fonts или название шрифта',
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
height: 1.35,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
textInputAction: TextInputAction.done,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'fonts.google.com/specimen/Roboto',
|
||||
filled: true,
|
||||
fillColor: cs.surface,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
onSubmitted: (v) => Navigator.pop(ctx, v),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, controller.text),
|
||||
child: const Text('Добавить'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
if (result != null) await _addFont(result);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final currentId = KometApp.stateOf(context)?.fontId ?? AppFonts.fallback.id;
|
||||
final app = KometApp.stateOf(context);
|
||||
final currentId = app?.fontId ?? AppFonts.fallback.id;
|
||||
final scale = app?.fontScale ?? AppFonts.defaultScale;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: cs.surface,
|
||||
@@ -42,14 +153,52 @@ class _CustomizationScreenState extends State<CustomizationScreen> {
|
||||
const SizedBox(height: 28),
|
||||
const _SectionLabel(icon: Symbols.text_fields, text: 'Шрифт'),
|
||||
const SizedBox(height: 14),
|
||||
for (final font in AppFonts.all) ...[
|
||||
for (final font in AppFonts.builtIn) ...[
|
||||
_FontOption(
|
||||
font: font,
|
||||
selected: font.id == currentId,
|
||||
onTap: () => _selectFont(font.id),
|
||||
),
|
||||
if (font != AppFonts.all.last) const SizedBox(height: 12),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
for (final family in _custom) ...[
|
||||
_FontOption(
|
||||
font: AppFonts.resolve(AppFonts.customId(family)),
|
||||
selected: AppFonts.customId(family) == currentId,
|
||||
onTap: () => _selectFont(AppFonts.customId(family)),
|
||||
onDelete: () => _removeFont(family),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
const SizedBox(height: 2),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ButtonM3E(
|
||||
onPressed: _showAddFontDialog,
|
||||
style: ButtonM3EStyle.outlined,
|
||||
size: ButtonM3ESize.lg,
|
||||
icon: const Icon(Symbols.add),
|
||||
label: const Text('Добавить шрифт'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
const _SectionLabel(
|
||||
icon: Symbols.format_size,
|
||||
text: 'Размер шрифта',
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
_FontSizeControl(
|
||||
scale: scale,
|
||||
onChanged: (v) => app?.applyFontScale(v, persist: false),
|
||||
onChangeEnd: (v) {
|
||||
Haptics.selection();
|
||||
app?.applyFontScale(v);
|
||||
},
|
||||
onReset: () {
|
||||
Haptics.selection();
|
||||
app?.applyFontScale(AppFonts.defaultScale);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -140,31 +289,126 @@ class _FontOption extends StatelessWidget {
|
||||
final AppFont font;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback? onDelete;
|
||||
|
||||
const _FontOption({
|
||||
required this.font,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
this.onDelete,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: ButtonM3E(
|
||||
onPressed: onTap,
|
||||
style: selected ? ButtonM3EStyle.filled : ButtonM3EStyle.tonal,
|
||||
size: ButtonM3ESize.xl,
|
||||
shape: ButtonM3EShape.round,
|
||||
selected: selected,
|
||||
icon: Icon(
|
||||
selected ? Symbols.check_circle : Symbols.font_download,
|
||||
fill: selected ? 1 : 0,
|
||||
),
|
||||
label: Text(
|
||||
font.label,
|
||||
style: AppFonts.sample(font.id, fontSize: 18),
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final button = ButtonM3E(
|
||||
onPressed: onTap,
|
||||
style: selected ? ButtonM3EStyle.filled : ButtonM3EStyle.tonal,
|
||||
size: ButtonM3ESize.xl,
|
||||
shape: ButtonM3EShape.round,
|
||||
selected: selected,
|
||||
icon: Icon(
|
||||
selected
|
||||
? Symbols.check_circle
|
||||
: (font.isSystem ? Symbols.smartphone : Symbols.font_download),
|
||||
fill: selected ? 1 : 0,
|
||||
),
|
||||
label: Text(
|
||||
font.label,
|
||||
style: AppFonts.sample(font.id, fontSize: 18),
|
||||
),
|
||||
);
|
||||
|
||||
if (onDelete == null) {
|
||||
return SizedBox(width: double.infinity, child: button);
|
||||
}
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(child: button),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
onPressed: onDelete,
|
||||
tooltip: 'Удалить',
|
||||
icon: Icon(Symbols.delete, color: cs.onSurfaceVariant, weight: 500),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FontSizeControl extends StatelessWidget {
|
||||
final double scale;
|
||||
final ValueChanged<double> onChanged;
|
||||
final ValueChanged<double> onChangeEnd;
|
||||
final VoidCallback onReset;
|
||||
|
||||
const _FontSizeControl({
|
||||
required this.scale,
|
||||
required this.onChanged,
|
||||
required this.onChangeEnd,
|
||||
required this.onReset,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final isDefault = (scale - AppFonts.defaultScale).abs() < 0.001;
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 12, 16),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'А',
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: SliderM3E(
|
||||
value: AppFonts.clampScale(scale),
|
||||
min: AppFonts.minScale,
|
||||
max: AppFonts.maxScale,
|
||||
divisions: 10,
|
||||
onChanged: onChanged,
|
||||
onChangeEnd: onChangeEnd,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'А',
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 24),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'${(scale * 100).round()}%',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
ButtonM3E(
|
||||
onPressed: isDefault ? null : onReset,
|
||||
enabled: !isDefault,
|
||||
style: ButtonM3EStyle.text,
|
||||
size: ButtonM3ESize.sm,
|
||||
label: const Text('Сбросить'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
+26
-1
@@ -62,12 +62,16 @@ void main() async {
|
||||
final initialVpnBypass = prefs.getBool(VpnBypassService.prefKey) ?? false;
|
||||
final initialFontId =
|
||||
prefs.getString(AppFonts.prefKey) ?? AppFonts.fallback.id;
|
||||
final initialFontScale = AppFonts.clampScale(
|
||||
prefs.getDouble(AppFonts.scalePrefKey) ?? AppFonts.defaultScale,
|
||||
);
|
||||
runApp(
|
||||
KometApp(
|
||||
initialLocale: initialLocale,
|
||||
initialFpsOverlay: initialFpsOverlay,
|
||||
initialVpnBypass: initialVpnBypass,
|
||||
initialFontId: initialFontId,
|
||||
initialFontScale: initialFontScale,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -79,12 +83,14 @@ class KometApp extends StatefulWidget {
|
||||
this.initialFpsOverlay = false,
|
||||
this.initialVpnBypass = false,
|
||||
required this.initialFontId,
|
||||
required this.initialFontScale,
|
||||
});
|
||||
|
||||
final Locale initialLocale;
|
||||
final bool initialFpsOverlay;
|
||||
final bool initialVpnBypass;
|
||||
final String initialFontId;
|
||||
final double initialFontScale;
|
||||
static final navigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
static KometAppState? stateOf(BuildContext context) {
|
||||
@@ -100,6 +106,7 @@ class KometAppState extends State<KometApp> {
|
||||
|
||||
late Locale _locale;
|
||||
late String _fontId;
|
||||
late double _fontScale;
|
||||
bool _isLoggingOut = false;
|
||||
StreamSubscription<SessionExpiredException>? _sessionExpiredSub;
|
||||
StreamSubscription<LoginStatus>? _loginStatusSub;
|
||||
@@ -120,6 +127,7 @@ class KometAppState extends State<KometApp> {
|
||||
super.initState();
|
||||
_locale = widget.initialLocale;
|
||||
_fontId = widget.initialFontId;
|
||||
_fontScale = widget.initialFontScale;
|
||||
|
||||
api.setReconnectCallback(() async {
|
||||
try {
|
||||
@@ -226,6 +234,7 @@ class KometAppState extends State<KometApp> {
|
||||
}
|
||||
|
||||
String get fontId => _fontId;
|
||||
double get fontScale => _fontScale;
|
||||
|
||||
Future<void> applyAppFont(String fontId) async {
|
||||
if (_fontId == fontId) return;
|
||||
@@ -236,6 +245,17 @@ class KometAppState extends State<KometApp> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> applyFontScale(double scale, {bool persist = true}) async {
|
||||
final next = AppFonts.clampScale(scale);
|
||||
if (_fontScale != next && mounted) {
|
||||
setState(() => _fontScale = next);
|
||||
}
|
||||
if (persist) {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setDouble(AppFonts.scalePrefKey, next);
|
||||
}
|
||||
}
|
||||
|
||||
void notifyProfileUpdate() {
|
||||
_profileUpdateController.add(null);
|
||||
}
|
||||
@@ -323,6 +343,11 @@ class KometAppState extends State<KometApp> {
|
||||
),
|
||||
navigatorKey: KometApp.navigatorKey,
|
||||
builder: (context, child) {
|
||||
final scaledChild = MediaQuery.withClampedTextScaling(
|
||||
minScaleFactor: _fontScale,
|
||||
maxScaleFactor: _fontScale,
|
||||
child: child ?? const SizedBox.shrink(),
|
||||
);
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: fpsOverlayEnabled,
|
||||
builder: (context, fpsOn, _) {
|
||||
@@ -330,7 +355,7 @@ class KometAppState extends State<KometApp> {
|
||||
fit: StackFit.expand,
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
child ?? const SizedBox.shrink(),
|
||||
scaledChild,
|
||||
if (fpsOn) const FpsOverlayLayer(),
|
||||
],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user