кастомизация темы — режим, AMOLED, расписание переключения
This commit is contained in:
@@ -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<bool> current = ValueNotifier(false);
|
||||||
|
|
||||||
|
static Future<bool> load() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(prefKey) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> save(bool value) async {
|
||||||
|
current.value = value;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(prefKey, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<AppThemeMode> current = ValueNotifier(
|
||||||
|
AppThemeMode.system,
|
||||||
|
);
|
||||||
|
|
||||||
|
static Future<AppThemeMode> load() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return _parse(prefs.getString(prefKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> 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 'По расписанию';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ThemeSchedule> current = ValueNotifier(
|
||||||
|
const ThemeSchedule(darkStart: _defaultStart, darkEnd: _defaultEnd),
|
||||||
|
);
|
||||||
|
|
||||||
|
static Future<ThemeSchedule> load() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return _parse(prefs.getString(prefKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> 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);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import 'package:material_symbols_icons/symbols.dart';
|
|||||||
import '../../../core/utils/haptics.dart';
|
import '../../../core/utils/haptics.dart';
|
||||||
import 'appearance_screen.dart';
|
import 'appearance_screen.dart';
|
||||||
import 'font_settings_screen.dart';
|
import 'font_settings_screen.dart';
|
||||||
|
import 'theme_settings_screen.dart';
|
||||||
|
|
||||||
class _CustomizationCategory {
|
class _CustomizationCategory {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
@@ -24,6 +25,12 @@ class CustomizationScreen extends StatelessWidget {
|
|||||||
const CustomizationScreen({super.key});
|
const CustomizationScreen({super.key});
|
||||||
|
|
||||||
static const List<_CustomizationCategory> _categories = [
|
static const List<_CustomizationCategory> _categories = [
|
||||||
|
_CustomizationCategory(
|
||||||
|
icon: Symbols.dark_mode,
|
||||||
|
title: 'Тема',
|
||||||
|
subtitle: 'Светлая, тёмная, AMOLED, расписание',
|
||||||
|
builder: _buildThemeSettings,
|
||||||
|
),
|
||||||
_CustomizationCategory(
|
_CustomizationCategory(
|
||||||
icon: Symbols.palette,
|
icon: Symbols.palette,
|
||||||
title: 'Внешний вид',
|
title: 'Внешний вид',
|
||||||
@@ -44,6 +51,9 @@ class CustomizationScreen extends StatelessWidget {
|
|||||||
static Widget _buildFontSettings(BuildContext context) =>
|
static Widget _buildFontSettings(BuildContext context) =>
|
||||||
const FontSettingsScreen();
|
const FontSettingsScreen();
|
||||||
|
|
||||||
|
static Widget _buildThemeSettings(BuildContext context) =>
|
||||||
|
const ThemeSettingsScreen();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final cs = Theme.of(context).colorScheme;
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
|||||||
@@ -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<AppThemeMode>(
|
||||||
|
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<bool>(
|
||||||
|
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<AppThemeMode>(
|
||||||
|
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<ThemeSchedule>(
|
||||||
|
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<TimeOfDay> 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<void> _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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+104
-2
@@ -8,10 +8,13 @@ import 'package:package_info_plus/package_info_plus.dart';
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'backend/api.dart';
|
import 'backend/api.dart';
|
||||||
import 'core/config/app_accent.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_behavior.dart';
|
||||||
import 'core/config/app_bubble_shape.dart';
|
import 'core/config/app_bubble_shape.dart';
|
||||||
import 'core/config/app_cache_extent.dart';
|
import 'core/config/app_cache_extent.dart';
|
||||||
import 'core/config/app_fonts.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/account.dart';
|
||||||
import 'backend/modules/chats.dart';
|
import 'backend/modules/chats.dart';
|
||||||
import 'backend/modules/contacts.dart';
|
import 'backend/modules/contacts.dart';
|
||||||
@@ -80,6 +83,9 @@ void main() async {
|
|||||||
AppBubbleShape.current.value = await AppBubbleShape.load();
|
AppBubbleShape.current.value = await AppBubbleShape.load();
|
||||||
AppBubbleBehavior.current.value = await AppBubbleBehavior.load();
|
AppBubbleBehavior.current.value = await AppBubbleBehavior.load();
|
||||||
AppCacheExtent.current.value = await AppCacheExtent.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(
|
runApp(
|
||||||
KometApp(
|
KometApp(
|
||||||
initialLocale: initialLocale,
|
initialLocale: initialLocale,
|
||||||
@@ -122,7 +128,7 @@ class KometApp extends StatefulWidget {
|
|||||||
State<KometApp> createState() => KometAppState();
|
State<KometApp> createState() => KometAppState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class KometAppState extends State<KometApp> {
|
class KometAppState extends State<KometApp> with WidgetsBindingObserver {
|
||||||
static const _fallbackSeed = Color(0xFFC1C4FF);
|
static const _fallbackSeed = Color(0xFFC1C4FF);
|
||||||
|
|
||||||
late Locale _locale;
|
late Locale _locale;
|
||||||
@@ -134,6 +140,7 @@ class KometAppState extends State<KometApp> {
|
|||||||
StreamSubscription<SessionExpiredException>? _sessionExpiredSub;
|
StreamSubscription<SessionExpiredException>? _sessionExpiredSub;
|
||||||
StreamSubscription<LoginStatus>? _loginStatusSub;
|
StreamSubscription<LoginStatus>? _loginStatusSub;
|
||||||
StreamSubscription<VpnBypassResult>? _vpnBypassSub;
|
StreamSubscription<VpnBypassResult>? _vpnBypassSub;
|
||||||
|
Timer? _scheduleTimer;
|
||||||
String? _lastVpnNotice;
|
String? _lastVpnNotice;
|
||||||
DateTime _lastVpnNoticeAt = DateTime.fromMillisecondsSinceEpoch(0);
|
DateTime _lastVpnNoticeAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
late final ValueNotifier<bool> fpsOverlayEnabled = ValueNotifier(
|
late final ValueNotifier<bool> fpsOverlayEnabled = ValueNotifier(
|
||||||
@@ -157,6 +164,13 @@ class KometAppState extends State<KometApp> {
|
|||||||
_locale = widget.initialLocale;
|
_locale = widget.initialLocale;
|
||||||
_fontId = widget.initialFontId;
|
_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 {
|
api.setReconnectCallback(() async {
|
||||||
try {
|
try {
|
||||||
final accountId = await TokenStorage.getActiveAccountId();
|
final accountId = await TokenStorage.getActiveAccountId();
|
||||||
@@ -228,6 +242,11 @@ class KometAppState extends State<KometApp> {
|
|||||||
_sessionExpiredSub?.cancel();
|
_sessionExpiredSub?.cancel();
|
||||||
_loginStatusSub?.cancel();
|
_loginStatusSub?.cancel();
|
||||||
_vpnBypassSub?.cancel();
|
_vpnBypassSub?.cancel();
|
||||||
|
_scheduleTimer?.cancel();
|
||||||
|
AppThemeModeConfig.current.removeListener(_onThemeModeChanged);
|
||||||
|
AppAmoled.current.removeListener(_onAmoledChanged);
|
||||||
|
AppThemeSchedule.current.removeListener(_onScheduleChanged);
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
_profileUpdateController.close();
|
_profileUpdateController.close();
|
||||||
fpsOverlayEnabled.dispose();
|
fpsOverlayEnabled.dispose();
|
||||||
vpnBypassEnabled.dispose();
|
vpnBypassEnabled.dispose();
|
||||||
@@ -237,6 +256,79 @@ class KometAppState extends State<KometApp> {
|
|||||||
super.dispose();
|
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<void> applyThemeMode(AppThemeMode mode) async {
|
||||||
|
await AppThemeModeConfig.save(mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> applyAmoled(bool value) async {
|
||||||
|
await AppAmoled.save(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> applyThemeSchedule(ThemeSchedule schedule) async {
|
||||||
|
await AppThemeSchedule.save(schedule);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> setFpsOverlayEnabled(bool value) async {
|
Future<void> setFpsOverlayEnabled(bool value) async {
|
||||||
if (fpsOverlayEnabled.value == value) return;
|
if (fpsOverlayEnabled.value == value) return;
|
||||||
fpsOverlayEnabled.value = value;
|
fpsOverlayEnabled.value = value;
|
||||||
@@ -359,6 +451,16 @@ class KometAppState extends State<KometApp> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ColorScheme _adjustDarkScheme(ColorScheme base) {
|
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(
|
return base.copyWith(
|
||||||
surface: Color.alphaBlend(
|
surface: Color.alphaBlend(
|
||||||
base.primary.withValues(alpha: 0.05),
|
base.primary.withValues(alpha: 0.05),
|
||||||
@@ -423,7 +525,7 @@ class KometAppState extends State<KometApp> {
|
|||||||
title: 'Komet',
|
title: 'Komet',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
locale: _locale,
|
locale: _locale,
|
||||||
themeMode: ThemeMode.system,
|
themeMode: _effectiveThemeMode,
|
||||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
theme: _lightTheme,
|
theme: _lightTheme,
|
||||||
|
|||||||
Reference in New Issue
Block a user