Files
Qlyra/lib/frontend/screens/profile/font_settings_screen.dart
T
2026-05-28 21:29:47 +07:00

421 lines
12 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:m3e_collection/m3e_collection.dart';
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 FontSettingsScreen extends StatefulWidget {
const FontSettingsScreen({super.key});
@override
State<FontSettingsScreen> createState() => _FontSettingsScreenState();
}
class _FontSettingsScreenState extends State<FontSettingsScreen> {
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;
Haptics.selection();
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 app = KometApp.stateOf(context);
final currentId = app?.fontId ?? AppFonts.fallback.id;
return Scaffold(
backgroundColor: cs.surface,
appBar: AppBarM3E(
titleText: 'Шрифты',
backgroundColor: cs.surface,
),
body: SafeArea(
top: false,
child: ListView(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 8, 16, 120),
children: [
_PreviewCard(fontId: currentId),
const SizedBox(height: 28),
const _SectionLabel(icon: Symbols.text_fields, text: 'Шрифт'),
const SizedBox(height: 14),
for (final font in AppFonts.builtIn) ...[
_FontOption(
font: font,
selected: font.id == currentId,
onTap: () => _selectFont(font.id),
),
const SizedBox(height: 8),
],
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: 8),
],
const SizedBox(height: 4),
SizedBox(
width: double.infinity,
child: ButtonM3E(
onPressed: _showAddFontDialog,
style: ButtonM3EStyle.outlined,
size: ButtonM3ESize.md,
icon: const Icon(Symbols.add),
label: const Text('Добавить шрифт'),
),
),
const SizedBox(height: 30),
const _SectionLabel(
icon: Symbols.format_size,
text: 'Размер шрифта',
),
const SizedBox(height: 6),
if (app != null)
ValueListenableBuilder<double>(
valueListenable: app.fontScale,
builder: (context, scale, _) => _FontSizeControl(
scale: scale,
onChanged: (v) => app.applyFontScale(v, persist: false),
onChangeEnd: (v) {
Haptics.selection();
app.applyFontScale(v);
},
onReset: () {
Haptics.selection();
app.applyFontScale(AppFonts.defaultScale);
},
),
),
],
),
),
);
}
}
class _PreviewCard extends StatelessWidget {
final String fontId;
const _PreviewCard({required this.fontId});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(28),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'ПРЕДПРОСМОТР',
style: TextStyle(
color: cs.primary,
fontSize: 12,
fontWeight: FontWeight.w700,
letterSpacing: 1.2,
),
),
const SizedBox(height: 16),
Text(
'Съешь ещё этих мягких булок',
style: AppFonts.sample(fontId, fontSize: 22).copyWith(
color: cs.onSurface,
fontWeight: FontWeight.w600,
height: 1.25,
),
),
const SizedBox(height: 10),
Text(
'The quick brown fox 0123',
style: AppFonts.sample(fontId, fontSize: 15).copyWith(
color: cs.onSurfaceVariant,
),
),
],
),
);
}
}
class _SectionLabel extends StatelessWidget {
final IconData icon;
final String text;
const _SectionLabel({required this.icon, required this.text});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.only(left: 6),
child: Row(
children: [
Icon(icon, size: 20, color: cs.onSurfaceVariant, weight: 500),
const SizedBox(width: 8),
Text(
text,
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 15,
fontWeight: FontWeight.w700,
letterSpacing: 0.3,
),
),
],
),
);
}
}
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) {
final cs = Theme.of(context).colorScheme;
final button = ButtonM3E(
onPressed: onTap,
style: selected ? ButtonM3EStyle.filled : ButtonM3EStyle.tonal,
size: ButtonM3ESize.md,
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: 16),
),
);
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:
((AppFonts.maxScale - AppFonts.minScale) / 0.05)
.round(),
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('Сбросить'),
),
],
),
],
),
);
}
}