diff --git a/lib/core/config/app_fonts.dart b/lib/core/config/app_fonts.dart index 6eb1ff8..74d3d59 100644 --- a/lib/core/config/app_fonts.dart +++ b/lib/core/config/app_fonts.dart @@ -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 all = [ + static const double minScale = 0.85; + static const double maxScale = 1.35; + static const double defaultScale = 1.0; + + static const List 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> loadCustomFamilies() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getStringList(customPrefKey) ?? const []; + } + + static Future addCustomFamily(String family) async { + final prefs = await SharedPreferences.getInstance(); + final list = prefs.getStringList(customPrefKey) ?? []; + if (!list.contains(family)) { + list.add(family); + await prefs.setStringList(customPrefKey, list); + } + } + + static Future removeCustomFamily(String family) async { + final prefs = await SharedPreferences.getInstance(); + final list = prefs.getStringList(customPrefKey) ?? []; + list.remove(family); + await prefs.setStringList(customPrefKey, list); } } diff --git a/lib/frontend/screens/profile/customization_screen.dart b/lib/frontend/screens/profile/customization_screen.dart index e746620..fb40931 100644 --- a/lib/frontend/screens/profile/customization_screen.dart +++ b/lib/frontend/screens/profile/customization_screen.dart @@ -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 { + List _custom = const []; + + @override + void initState() { + super.initState(); + _reloadCustom(); + } + + Future _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 { app.applyAppFont(id); } + Future _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 _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 _showAddFontDialog() async { + final cs = Theme.of(context).colorScheme; + final controller = TextEditingController(); + final result = await showDialog( + 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 { 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 onChanged; + final ValueChanged 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('Сбросить'), + ), + ], + ), + ], ), ); } diff --git a/lib/main.dart b/lib/main.dart index 5513b1f..548bc26 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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(); static KometAppState? stateOf(BuildContext context) { @@ -100,6 +106,7 @@ class KometAppState extends State { late Locale _locale; late String _fontId; + late double _fontScale; bool _isLoggingOut = false; StreamSubscription? _sessionExpiredSub; StreamSubscription? _loginStatusSub; @@ -120,6 +127,7 @@ class KometAppState extends State { super.initState(); _locale = widget.initialLocale; _fontId = widget.initialFontId; + _fontScale = widget.initialFontScale; api.setReconnectCallback(() async { try { @@ -226,6 +234,7 @@ class KometAppState extends State { } String get fontId => _fontId; + double get fontScale => _fontScale; Future applyAppFont(String fontId) async { if (_fontId == fontId) return; @@ -236,6 +245,17 @@ class KometAppState extends State { } } + Future 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 { ), navigatorKey: KometApp.navigatorKey, builder: (context, child) { + final scaledChild = MediaQuery.withClampedTextScaling( + minScaleFactor: _fontScale, + maxScaleFactor: _fontScale, + child: child ?? const SizedBox.shrink(), + ); return ValueListenableBuilder( valueListenable: fpsOverlayEnabled, builder: (context, fpsOn, _) { @@ -330,7 +355,7 @@ class KometAppState extends State { fit: StackFit.expand, clipBehavior: Clip.none, children: [ - child ?? const SizedBox.shrink(), + scaledChild, if (fpsOn) const FpsOverlayLayer(), ], );