From 2fb4d89f79f9d85ca6652b8784df0271af233562 Mon Sep 17 00:00:00 2001 From: klockky Date: Thu, 21 May 2026 05:59:34 +0000 Subject: [PATCH] =?UTF-8?q?=D0=BA=D0=B0=D1=81=D1=82=D0=BE=D0=BC=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D1=82=D0=B5=D0=BC=D1=8B=20?= =?UTF-8?q?=E2=80=94=20=D1=80=D0=B5=D0=B6=D0=B8=D0=BC,=20AMOLED,=20=D1=80?= =?UTF-8?q?=D0=B0=D1=81=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/config/app_amoled.dart | 18 + lib/core/config/app_theme_mode.dart | 48 +++ lib/core/config/app_theme_schedule.dart | 92 +++++ .../screens/profile/customization_screen.dart | 10 + .../profile/theme_settings_screen.dart | 359 ++++++++++++++++++ lib/main.dart | 106 +++++- 6 files changed, 631 insertions(+), 2 deletions(-) create mode 100644 lib/core/config/app_amoled.dart create mode 100644 lib/core/config/app_theme_mode.dart create mode 100644 lib/core/config/app_theme_schedule.dart create mode 100644 lib/frontend/screens/profile/theme_settings_screen.dart diff --git a/lib/core/config/app_amoled.dart b/lib/core/config/app_amoled.dart new file mode 100644 index 0000000..71def4b --- /dev/null +++ b/lib/core/config/app_amoled.dart @@ -0,0 +1,18 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class AppAmoled { + static const prefKey = 'app_amoled'; + static final ValueNotifier current = ValueNotifier(false); + + static Future load() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(prefKey) ?? false; + } + + static Future save(bool value) async { + current.value = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(prefKey, value); + } +} diff --git a/lib/core/config/app_theme_mode.dart b/lib/core/config/app_theme_mode.dart new file mode 100644 index 0000000..be141e1 --- /dev/null +++ b/lib/core/config/app_theme_mode.dart @@ -0,0 +1,48 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +enum AppThemeMode { system, light, dark, schedule } + +class AppThemeModeConfig { + static const prefKey = 'app_theme_mode'; + static final ValueNotifier current = ValueNotifier( + AppThemeMode.system, + ); + + static Future load() async { + final prefs = await SharedPreferences.getInstance(); + return _parse(prefs.getString(prefKey)); + } + + static Future save(AppThemeMode mode) async { + current.value = mode; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(prefKey, mode.name); + } + + static AppThemeMode _parse(String? val) { + switch (val) { + case 'light': + return AppThemeMode.light; + case 'dark': + return AppThemeMode.dark; + case 'schedule': + return AppThemeMode.schedule; + default: + return AppThemeMode.system; + } + } + + static String label(AppThemeMode mode) { + switch (mode) { + case AppThemeMode.system: + return 'Системная'; + case AppThemeMode.light: + return 'Светлая'; + case AppThemeMode.dark: + return 'Тёмная'; + case AppThemeMode.schedule: + return 'По расписанию'; + } + } +} diff --git a/lib/core/config/app_theme_schedule.dart b/lib/core/config/app_theme_schedule.dart new file mode 100644 index 0000000..3c62c7f --- /dev/null +++ b/lib/core/config/app_theme_schedule.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class ThemeSchedule { + final TimeOfDay darkStart; + final TimeOfDay darkEnd; + + const ThemeSchedule({required this.darkStart, required this.darkEnd}); + + bool isDarkAt(DateTime now) { + final nowMin = now.hour * 60 + now.minute; + final startMin = darkStart.hour * 60 + darkStart.minute; + final endMin = darkEnd.hour * 60 + darkEnd.minute; + if (startMin == endMin) return false; + if (startMin < endMin) { + return nowMin >= startMin && nowMin < endMin; + } + return nowMin >= startMin || nowMin < endMin; + } + + Duration durationUntilNextSwitch(DateTime now) { + final nowMin = now.hour * 60 + now.minute; + final startMin = darkStart.hour * 60 + darkStart.minute; + final endMin = darkEnd.hour * 60 + darkEnd.minute; + int? nextMin; + for (final m in [startMin, endMin]) { + final delta = (m - nowMin + 1440) % 1440; + final candidate = delta == 0 ? 1440 : delta; + if (nextMin == null || candidate < nextMin) nextMin = candidate; + } + final secondsLeft = (nextMin ?? 1) * 60 - now.second; + return Duration(seconds: secondsLeft.clamp(1, 24 * 60 * 60)); + } +} + +class AppThemeSchedule { + static const prefKey = 'app_theme_schedule'; + static const _defaultStart = TimeOfDay(hour: 22, minute: 0); + static const _defaultEnd = TimeOfDay(hour: 7, minute: 0); + + static final ValueNotifier current = ValueNotifier( + const ThemeSchedule(darkStart: _defaultStart, darkEnd: _defaultEnd), + ); + + static Future load() async { + final prefs = await SharedPreferences.getInstance(); + return _parse(prefs.getString(prefKey)); + } + + static Future save(ThemeSchedule schedule) async { + current.value = schedule; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + prefKey, + '${_fmt(schedule.darkStart)}-${_fmt(schedule.darkEnd)}', + ); + } + + static String _fmt(TimeOfDay t) => + '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}'; + + static ThemeSchedule _parse(String? val) { + if (val == null) { + return const ThemeSchedule( + darkStart: _defaultStart, + darkEnd: _defaultEnd, + ); + } + final parts = val.split('-'); + if (parts.length != 2) { + return const ThemeSchedule( + darkStart: _defaultStart, + darkEnd: _defaultEnd, + ); + } + final start = _parseTime(parts[0]) ?? _defaultStart; + final end = _parseTime(parts[1]) ?? _defaultEnd; + return ThemeSchedule(darkStart: start, darkEnd: end); + } + + static TimeOfDay? _parseTime(String val) { + final parts = val.split(':'); + if (parts.length != 2) return null; + final h = int.tryParse(parts[0]); + final m = int.tryParse(parts[1]); + if (h == null || m == null) return null; + if (h < 0 || h > 23 || m < 0 || m > 59) return null; + return TimeOfDay(hour: h, minute: m); + } + + static String format(TimeOfDay t) => _fmt(t); +} diff --git a/lib/frontend/screens/profile/customization_screen.dart b/lib/frontend/screens/profile/customization_screen.dart index f3bd4ee..ff447a7 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/utils/haptics.dart'; import 'appearance_screen.dart'; import 'font_settings_screen.dart'; +import 'theme_settings_screen.dart'; class _CustomizationCategory { final IconData icon; @@ -24,6 +25,12 @@ class CustomizationScreen extends StatelessWidget { const CustomizationScreen({super.key}); static const List<_CustomizationCategory> _categories = [ + _CustomizationCategory( + icon: Symbols.dark_mode, + title: 'Тема', + subtitle: 'Светлая, тёмная, AMOLED, расписание', + builder: _buildThemeSettings, + ), _CustomizationCategory( icon: Symbols.palette, title: 'Внешний вид', @@ -44,6 +51,9 @@ class CustomizationScreen extends StatelessWidget { static Widget _buildFontSettings(BuildContext context) => const FontSettingsScreen(); + static Widget _buildThemeSettings(BuildContext context) => + const ThemeSettingsScreen(); + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; diff --git a/lib/frontend/screens/profile/theme_settings_screen.dart b/lib/frontend/screens/profile/theme_settings_screen.dart new file mode 100644 index 0000000..9e08c3d --- /dev/null +++ b/lib/frontend/screens/profile/theme_settings_screen.dart @@ -0,0 +1,359 @@ +import 'package:flutter/material.dart'; +import 'package:m3e_collection/m3e_collection.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../core/config/app_amoled.dart'; +import '../../../core/config/app_theme_mode.dart'; +import '../../../core/config/app_theme_schedule.dart'; +import '../../../core/utils/haptics.dart'; +import '../../../main.dart'; + +class ThemeSettingsScreen extends StatelessWidget { + const ThemeSettingsScreen({super.key}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + 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, 12, 16, 120), + children: const [ + _ThemeModeCard(), + SizedBox(height: 12), + _AmoledCard(), + SizedBox(height: 12), + _ScheduleCard(), + ], + ), + ), + ); + } +} + +class _ThemeModeCard extends StatelessWidget { + const _ThemeModeCard(); + + static const _items = [ + (mode: AppThemeMode.system, icon: Symbols.brightness_auto, label: 'Системная'), + (mode: AppThemeMode.light, icon: Symbols.light_mode, label: 'Светлая'), + (mode: AppThemeMode.dark, icon: Symbols.dark_mode, label: 'Тёмная'), + (mode: AppThemeMode.schedule, icon: Symbols.schedule, label: 'По расписанию'), + ]; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Material( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(28), + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Режим темы', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + 'Светлая, тёмная или авто-переключение', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + const SizedBox(height: 8), + ValueListenableBuilder( + valueListenable: AppThemeModeConfig.current, + builder: (context, current, _) { + return Column( + children: [ + for (final item in _items) + _ModeTile( + icon: item.icon, + label: item.label, + selected: current == item.mode, + onTap: () { + if (current == item.mode) return; + Haptics.selection(); + KometApp.stateOf(context)?.applyThemeMode(item.mode); + }, + ), + ], + ); + }, + ), + ], + ), + ), + ); + } +} + +class _ModeTile extends StatelessWidget { + final IconData icon; + final String label; + final bool selected; + final VoidCallback onTap; + + const _ModeTile({ + required this.icon, + required this.label, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), + child: Row( + children: [ + Icon(icon, color: cs.onSurface, size: 22, weight: 500), + const SizedBox(width: 14), + Expanded( + child: Text( + label, + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ), + ), + Icon( + selected ? Symbols.radio_button_checked : Symbols.radio_button_unchecked, + color: selected ? cs.primary : cs.outline, + size: 22, + fill: selected ? 1 : 0, + ), + ], + ), + ), + ), + ); + } +} + +class _AmoledCard extends StatelessWidget { + const _AmoledCard(); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Material( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(28), + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 14, 12, 14), + child: Row( + children: [ + Icon(Symbols.contrast, color: cs.onSurface, size: 24, weight: 500), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'AMOLED-чёрный', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + 'Чистый чёрный фон для OLED-экранов', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ], + ), + ), + ValueListenableBuilder( + valueListenable: AppAmoled.current, + builder: (context, value, _) { + return Switch( + value: value, + onChanged: (v) { + Haptics.selection(); + KometApp.stateOf(context)?.applyAmoled(v); + }, + ); + }, + ), + ], + ), + ), + ); + } +} + +class _ScheduleCard extends StatelessWidget { + const _ScheduleCard(); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return ValueListenableBuilder( + valueListenable: AppThemeModeConfig.current, + builder: (context, mode, _) { + final enabled = mode == AppThemeMode.schedule; + return AnimatedOpacity( + opacity: enabled ? 1 : 0.5, + duration: const Duration(milliseconds: 200), + child: Material( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(28), + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Расписание', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + enabled + ? 'Когда автоматически включается тёмная тема' + : 'Доступно в режиме «По расписанию»', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + const SizedBox(height: 12), + ValueListenableBuilder( + valueListenable: AppThemeSchedule.current, + builder: (context, schedule, _) { + return Column( + children: [ + _TimeRow( + icon: Symbols.bedtime, + label: 'Тёмная с', + time: schedule.darkStart, + enabled: enabled, + onPick: (picked) { + KometApp.stateOf(context)?.applyThemeSchedule( + ThemeSchedule( + darkStart: picked, + darkEnd: schedule.darkEnd, + ), + ); + }, + ), + const SizedBox(height: 8), + _TimeRow( + icon: Symbols.wb_sunny, + label: 'Светлая с', + time: schedule.darkEnd, + enabled: enabled, + onPick: (picked) { + KometApp.stateOf(context)?.applyThemeSchedule( + ThemeSchedule( + darkStart: schedule.darkStart, + darkEnd: picked, + ), + ); + }, + ), + ], + ); + }, + ), + ], + ), + ), + ), + ); + }, + ); + } +} + +class _TimeRow extends StatelessWidget { + final IconData icon; + final String label; + final TimeOfDay time; + final bool enabled; + final ValueChanged onPick; + + const _TimeRow({ + required this.icon, + required this.label, + required this.time, + required this.enabled, + required this.onPick, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Material( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(16), + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: enabled ? () => _pick(context) : null, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + child: Row( + children: [ + Icon(icon, color: cs.onSurface, size: 22, weight: 500), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ), + ), + Text( + AppThemeSchedule.format(time), + style: TextStyle( + color: cs.primary, + fontSize: 16, + fontWeight: FontWeight.w700, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ), + ), + ), + ); + } + + Future _pick(BuildContext context) async { + Haptics.tap(); + final picked = await showTimePicker( + context: context, + initialTime: time, + builder: (ctx, child) => MediaQuery( + data: MediaQuery.of(ctx).copyWith(alwaysUse24HourFormat: true), + child: child ?? const SizedBox.shrink(), + ), + ); + if (picked != null) onPick(picked); + } +} diff --git a/lib/main.dart b/lib/main.dart index a342bbf..1fcc56e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,10 +8,13 @@ import 'package:package_info_plus/package_info_plus.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'backend/api.dart'; import 'core/config/app_accent.dart'; +import 'core/config/app_amoled.dart'; import 'core/config/app_bubble_behavior.dart'; import 'core/config/app_bubble_shape.dart'; import 'core/config/app_cache_extent.dart'; import 'core/config/app_fonts.dart'; +import 'core/config/app_theme_mode.dart'; +import 'core/config/app_theme_schedule.dart'; import 'backend/modules/account.dart'; import 'backend/modules/chats.dart'; import 'backend/modules/contacts.dart'; @@ -80,6 +83,9 @@ void main() async { AppBubbleShape.current.value = await AppBubbleShape.load(); AppBubbleBehavior.current.value = await AppBubbleBehavior.load(); AppCacheExtent.current.value = await AppCacheExtent.load(); + AppThemeModeConfig.current.value = await AppThemeModeConfig.load(); + AppAmoled.current.value = await AppAmoled.load(); + AppThemeSchedule.current.value = await AppThemeSchedule.load(); runApp( KometApp( initialLocale: initialLocale, @@ -122,7 +128,7 @@ class KometApp extends StatefulWidget { State createState() => KometAppState(); } -class KometAppState extends State { +class KometAppState extends State with WidgetsBindingObserver { static const _fallbackSeed = Color(0xFFC1C4FF); late Locale _locale; @@ -134,6 +140,7 @@ class KometAppState extends State { StreamSubscription? _sessionExpiredSub; StreamSubscription? _loginStatusSub; StreamSubscription? _vpnBypassSub; + Timer? _scheduleTimer; String? _lastVpnNotice; DateTime _lastVpnNoticeAt = DateTime.fromMillisecondsSinceEpoch(0); late final ValueNotifier fpsOverlayEnabled = ValueNotifier( @@ -157,6 +164,13 @@ class KometAppState extends State { _locale = widget.initialLocale; _fontId = widget.initialFontId; + WidgetsBinding.instance.addObserver(this); + AppThemeModeConfig.current.addListener(_onThemeModeChanged); + AppAmoled.current.addListener(_onAmoledChanged); + AppThemeSchedule.current.addListener(_onScheduleChanged); + _lastAppliedThemeMode = _effectiveThemeMode; + _rescheduleSwitch(); + api.setReconnectCallback(() async { try { final accountId = await TokenStorage.getActiveAccountId(); @@ -228,6 +242,11 @@ class KometAppState extends State { _sessionExpiredSub?.cancel(); _loginStatusSub?.cancel(); _vpnBypassSub?.cancel(); + _scheduleTimer?.cancel(); + AppThemeModeConfig.current.removeListener(_onThemeModeChanged); + AppAmoled.current.removeListener(_onAmoledChanged); + AppThemeSchedule.current.removeListener(_onScheduleChanged); + WidgetsBinding.instance.removeObserver(this); _profileUpdateController.close(); fpsOverlayEnabled.dispose(); vpnBypassEnabled.dispose(); @@ -237,6 +256,79 @@ class KometAppState extends State { super.dispose(); } + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state != AppLifecycleState.resumed) return; + if (AppThemeModeConfig.current.value != AppThemeMode.schedule) return; + _rescheduleSwitch(); + final next = _effectiveThemeMode; + if (next == _lastAppliedThemeMode) return; + _lastAppliedThemeMode = next; + if (mounted) setState(() {}); + } + + void _onThemeModeChanged() { + _rescheduleSwitch(); + _lastAppliedThemeMode = _effectiveThemeMode; + if (mounted) setState(() {}); + } + + void _onAmoledChanged() { + if (mounted) setState(() {}); + } + + void _onScheduleChanged() { + if (AppThemeModeConfig.current.value != AppThemeMode.schedule) return; + _rescheduleSwitch(); + final next = _effectiveThemeMode; + if (next == _lastAppliedThemeMode) return; + _lastAppliedThemeMode = next; + if (mounted) setState(() {}); + } + + ThemeMode _lastAppliedThemeMode = ThemeMode.system; + + void _rescheduleSwitch() { + _scheduleTimer?.cancel(); + _scheduleTimer = null; + if (AppThemeModeConfig.current.value != AppThemeMode.schedule) return; + final until = AppThemeSchedule.current.value.durationUntilNextSwitch( + DateTime.now(), + ); + _scheduleTimer = Timer(until, () { + if (!mounted) return; + _lastAppliedThemeMode = _effectiveThemeMode; + setState(() {}); + _rescheduleSwitch(); + }); + } + + ThemeMode get _effectiveThemeMode { + switch (AppThemeModeConfig.current.value) { + case AppThemeMode.system: + return ThemeMode.system; + case AppThemeMode.light: + return ThemeMode.light; + case AppThemeMode.dark: + return ThemeMode.dark; + case AppThemeMode.schedule: + final isDark = AppThemeSchedule.current.value.isDarkAt(DateTime.now()); + return isDark ? ThemeMode.dark : ThemeMode.light; + } + } + + Future applyThemeMode(AppThemeMode mode) async { + await AppThemeModeConfig.save(mode); + } + + Future applyAmoled(bool value) async { + await AppAmoled.save(value); + } + + Future applyThemeSchedule(ThemeSchedule schedule) async { + await AppThemeSchedule.save(schedule); + } + Future setFpsOverlayEnabled(bool value) async { if (fpsOverlayEnabled.value == value) return; fpsOverlayEnabled.value = value; @@ -359,6 +451,16 @@ class KometAppState extends State { } ColorScheme _adjustDarkScheme(ColorScheme base) { + if (AppAmoled.current.value) { + return base.copyWith( + surface: Colors.black, + surfaceContainerLowest: Colors.black, + surfaceContainerLow: const Color(0xFF080808), + surfaceContainer: const Color(0xFF101010), + surfaceContainerHigh: const Color(0xFF161616), + surfaceContainerHighest: const Color(0xFF1C1C1C), + ); + } return base.copyWith( surface: Color.alphaBlend( base.primary.withValues(alpha: 0.05), @@ -423,7 +525,7 @@ class KometAppState extends State { title: 'Komet', debugShowCheckedModeBanner: false, locale: _locale, - themeMode: ThemeMode.system, + themeMode: _effectiveThemeMode, localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, theme: _lightTheme,