import 'dart:async'; import 'dart:math' as math; import 'dart:ui' as ui; import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:komet/l10n/app_localizations.dart'; import 'package:m3e_collection/m3e_collection.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'backend/api.dart'; import 'core/cache/info_cache.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_message_actions_style.dart'; import 'core/config/app_swipe_back_desktop.dart'; import 'core/config/app_pranks.dart'; import 'core/config/app_stories.dart'; import 'core/config/app_link_preview.dart'; import 'core/config/app_media_cache.dart'; import 'core/config/app_theme_mode.dart'; import 'core/config/app_theme_schedule.dart'; import 'core/config/app_digital_id_mode.dart'; import 'backend/modules/account.dart'; import 'backend/modules/chats.dart'; import 'backend/modules/contacts.dart'; import 'backend/modules/file_uploader.dart'; import 'backend/modules/messages.dart'; import 'backend/modules/polls.dart'; import 'backend/modules/webapp.dart'; import 'backend/modules/digital_id.dart'; import 'core/calls/call_controller.dart'; import 'frontend/screens/calls/call_screen.dart'; import 'core/push/push_service.dart'; import 'core/storage/app_database.dart'; import 'core/transport/tls_config.dart'; import 'core/transport/vpn_bypass.dart'; import 'core/storage/token_storage.dart'; import 'core/utils/haptics.dart'; import 'core/protocol/packet.dart'; import 'frontend/debug/fps_overlay_layer.dart'; import 'frontend/screens/auth/login_screen.dart'; import 'frontend/widgets/adaptive_shell.dart'; import 'frontend/widgets/custom_notification.dart'; import 'frontend/widgets/theme_reveal.dart'; final api = Api(); final accountModule = AccountModule(api); final messagesModule = MessagesModule(api); final pollsModule = PollsModule(api); final webAppModule = WebAppModule(api); final digitalIdModule = DigitalIdModule(webAppModule); final fileUploader = FileUploader(api: api, messages: messagesModule); final RouteObserver> appRouteObserver = RouteObserver>(); bool isOnemeFlavor = false; Future _loadInitialLocale() async { final prefs = await SharedPreferences.getInstance(); final code = prefs.getString('app_locale'); if (code != null && (code == 'en' || code == 'ru')) { return Locale(code); } final platform = WidgetsBinding.instance.platformDispatcher.locale; if (platform.languageCode == 'en' || platform.languageCode == 'ru') { return Locale(platform.languageCode); } return const Locale('ru'); } void main() async { WidgetsFlutterBinding.ensureInitialized(); await AppDatabase.init(); final activeAccountId = await TokenStorage.getActiveAccountId(); if (activeAccountId != null) { await ContactsModule.primeCacheFromDb(activeAccountId); } attachInfoCacheApi(api); ChatsModule.attachGlobalPushHandlers(api); final packageInfoFuture = PackageInfo.fromPlatform(); final localeFuture = _loadInitialLocale(); final hapticsFuture = Haptics.load(); final prefsFuture = SharedPreferences.getInstance(); final accentFuture = AppAccent.load(); final bubbleShapeFuture = AppBubbleShape.load(); final bubbleBehaviorFuture = AppBubbleBehavior.load(); final cacheExtentFuture = AppCacheExtent.load(); final themeModeFuture = AppThemeModeConfig.load(); final amoledFuture = AppAmoled.load(); final themeScheduleFuture = AppThemeSchedule.load(); final messageActionsFuture = AppMessageActionsStyle.load(); final swipeBackFuture = AppSwipeBackDesktop.load(); final pranksFuture = AppPranks.load(); final storiesFuture = AppStories.load(); final linkPreviewFuture = AppLinkPreview.load(); final cacheLimitFuture = AppMediaCacheLimit.load(); final digitalIdNativeFuture = AppDigitalIdNative.load(); final packageInfo = await packageInfoFuture; isOnemeFlavor = packageInfo.packageName == 'ru.oneme.app'; final initialLocale = await localeFuture; await hapticsFuture; final prefs = await prefsFuture; await FileHistoryCache.load(prefs); final initialFpsOverlay = prefs.getBool('dev_fps_overlay') ?? false; final initialVpnBypass = prefs.getBool(VpnBypassService.prefKey) ?? false; final initialTlsInsecure = prefs.getBool(TlsConfig.prefKey) ?? false; final initialFontId = prefs.getString(AppFonts.prefKey) ?? AppFonts.fallback.id; final initialFontScale = AppFonts.clampScale( prefs.getDouble(AppFonts.scalePrefKey) ?? AppFonts.defaultScale, ); final initialAccentSeed = await accentFuture; AppBubbleShape.current.value = await bubbleShapeFuture; AppBubbleBehavior.current.value = await bubbleBehaviorFuture; AppCacheExtent.current.value = await cacheExtentFuture; AppThemeModeConfig.current.value = await themeModeFuture; AppAmoled.current.value = await amoledFuture; AppThemeSchedule.current.value = await themeScheduleFuture; AppMessageActionsStyle.current.value = await messageActionsFuture; AppSwipeBackDesktop.current.value = await swipeBackFuture; AppPranks.current.value = await pranksFuture; AppStories.current.value = await storiesFuture; AppLinkPreview.current.value = await linkPreviewFuture; AppMediaCacheLimit.current.value = await cacheLimitFuture; AppDigitalIdNative.current.value = await digitalIdNativeFuture; runApp( KometApp( initialLocale: initialLocale, initialFpsOverlay: initialFpsOverlay, initialVpnBypass: initialVpnBypass, initialTlsInsecure: initialTlsInsecure, initialFontId: initialFontId, initialFontScale: initialFontScale, initialAccentSeed: initialAccentSeed, ), ); } class KometApp extends StatefulWidget { const KometApp({ super.key, required this.initialLocale, this.initialFpsOverlay = false, this.initialVpnBypass = false, this.initialTlsInsecure = false, required this.initialFontId, required this.initialFontScale, this.initialAccentSeed, }); final Locale initialLocale; final bool initialFpsOverlay; final bool initialVpnBypass; final bool initialTlsInsecure; final String initialFontId; final double initialFontScale; final Color? initialAccentSeed; static final navigatorKey = GlobalKey(); static KometAppState? stateOf(BuildContext context) { return context.findAncestorStateOfType(); } @override State createState() => KometAppState(); } class KometAppState extends State with WidgetsBindingObserver, TickerProviderStateMixin { static const _fallbackSeed = Color(0xFFC1C4FF); final GlobalKey _captureBoundaryKey = GlobalKey(); OverlayEntry? _revealEntry; AnimationController? _revealController; ui.Image? _revealImage; late Locale _locale; late String _fontId; bool _isLoggingOut = false; late final ValueNotifier accentSeed = ValueNotifier( widget.initialAccentSeed, ); StreamSubscription? _sessionExpiredSub; StreamSubscription? _loginStatusSub; StreamSubscription? _vpnBypassSub; StreamSubscription? _callIncomingSub; Timer? _scheduleTimer; String? _lastVpnNotice; DateTime _lastVpnNoticeAt = DateTime.fromMillisecondsSinceEpoch(0); late final ValueNotifier fpsOverlayEnabled = ValueNotifier( widget.initialFpsOverlay, ); late final ValueNotifier vpnBypassEnabled = ValueNotifier( widget.initialVpnBypass, ); late final ValueNotifier tlsInsecureEnabled = ValueNotifier( widget.initialTlsInsecure, ); late final ValueNotifier fontScale = ValueNotifier( widget.initialFontScale, ); final _profileUpdateController = StreamController.broadcast(); Stream get profileUpdateStream => _profileUpdateController.stream; @override void initState() { super.initState(); _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(); if (accountId != null) { final token = await TokenStorage.readToken(accountId); if (token != null) { await accountModule.login(accountId: accountId, token: token); } } } catch (_) {} }); _loginStatusSub = accountModule.loginStatusStream.listen((status) async { if (status == LoginStatus.success) { CallController.instance.init(api); if (isOnemeFlavor) { await PushService.instance.init(api: api, account: accountModule); await PushService.instance.onLoginSuccess(); } } }); _callIncomingSub = CallController.instance.incomingCalls.listen( _onIncomingCall, ); _sessionExpiredSub = api.sessionExpiredStream.listen(( SessionExpiredException e, ) async { if (_isLoggingOut) return; _isLoggingOut = true; await PushService.instance.unregister(); final accountId = await TokenStorage.getActiveAccountId(); if (accountId != null) { await accountModule.removeAccount(accountId); } final navState = KometApp.navigatorKey.currentState; if (navState != null) { final overlay = navState.overlay; if (overlay != null) { showCustomNotificationOnOverlay(overlay, e.message); } await navState.pushAndRemoveUntil( MaterialPageRoute(builder: (_) => const LoginScreen()), (route) => false, ); } _isLoggingOut = false; }); _vpnBypassSub = VpnBypassService.instance.events.listen((r) { final msg = r.bound ? 'Соединение через VPN не работает — ' 'используется ${r.boundInterface ?? r.transport ?? 'прямое подключение'}' : 'Соединение через VPN не работает, обойти не удалось' '${r.reason != null ? ' (${r.reason})' : ''}'; final now = DateTime.now(); if (msg == _lastVpnNotice && now.difference(_lastVpnNoticeAt).inSeconds < 10) { return; } _lastVpnNotice = msg; _lastVpnNoticeAt = now; final overlay = KometApp.navigatorKey.currentState?.overlay; if (overlay != null) { showCustomNotificationOnOverlay(overlay, msg); } }); } Future _onIncomingCall(IncomingCall call) async { final navState = KometApp.navigatorKey.currentState; if (navState == null) return; navState.push( MaterialPageRoute( builder: (_) => CallScreen( name: ContactCache.get(call.callerId) ?? '', avatarUrl: ContactCache.getAvatar(call.callerId), incoming: call, ), ), ); } @override void dispose() { _finishReveal(); _sessionExpiredSub?.cancel(); _loginStatusSub?.cancel(); _vpnBypassSub?.cancel(); _callIncomingSub?.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(); tlsInsecureEnabled.dispose(); fontScale.dispose(); accentSeed.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 applyThemeMode(AppThemeMode mode) async { await AppThemeModeConfig.save(mode); } void applyThemeModeWithReveal(AppThemeMode mode, Offset center) { if (AppThemeModeConfig.current.value == mode) return; _runThemeReveal(center, () => AppThemeModeConfig.save(mode)); } Future applyAmoled(bool value) async { await AppAmoled.save(value); } void applyAmoledWithReveal(bool value, Offset center) { if (AppAmoled.current.value == value) return; _runThemeReveal(center, () => AppAmoled.save(value)); } void _runThemeReveal(Offset center, Future Function() apply) { final overlay = KometApp.navigatorKey.currentState?.overlay; final ctx = _captureBoundaryKey.currentContext; if (overlay == null || ctx == null) { apply(); return; } if (MediaQuery.disableAnimationsOf(ctx)) { apply(); return; } final renderObject = ctx.findRenderObject(); if (renderObject is! RenderRepaintBoundary) { apply(); return; } final ui.Image snapshot; try { final dpr = math.min(MediaQuery.of(ctx).devicePixelRatio, 2.0); snapshot = renderObject.toImageSync(pixelRatio: dpr); } catch (_) { apply(); return; } _finishReveal(); final controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 650), ); final entry = ThemeRevealOverlay.build( snapshot: snapshot, center: center, animation: controller, ); _revealController = controller; _revealEntry = entry; _revealImage = snapshot; overlay.insert(entry); apply(); WidgetsBinding.instance.endOfFrame.then((_) { if (_revealController != controller) return; controller.forward().then((_) { if (_revealController != controller) return; _finishReveal(); }, onError: (_) {}); }); } void _finishReveal() { _revealEntry?.remove(); _revealEntry = null; _revealController?.dispose(); _revealController = null; final img = _revealImage; _revealImage = null; if (img != null) { WidgetsBinding.instance.addPostFrameCallback((_) => img.dispose()); } } Future applyThemeSchedule(ThemeSchedule schedule) async { await AppThemeSchedule.save(schedule); } Future setFpsOverlayEnabled(bool value) async { if (fpsOverlayEnabled.value == value) return; fpsOverlayEnabled.value = value; final prefs = await SharedPreferences.getInstance(); await prefs.setBool('dev_fps_overlay', value); } Future setVpnBypassEnabled(bool value) async { if (vpnBypassEnabled.value == value) return; vpnBypassEnabled.value = value; final prefs = await SharedPreferences.getInstance(); await prefs.setBool(VpnBypassService.prefKey, value); } Future setTlsInsecureEnabled(bool value) async { if (tlsInsecureEnabled.value == value) return; tlsInsecureEnabled.value = value; await TlsConfig.setInsecureAllowed(value); } Future applyLocale(Locale locale) async { if (!AppLocalizations.supportedLocales.any( (l) => l.languageCode == locale.languageCode, )) { return; } final prefs = await SharedPreferences.getInstance(); await prefs.setString('app_locale', locale.languageCode); if (mounted) { setState(() => _locale = locale); } } String get fontId => _fontId; Future applyAccentColor(Color? seed) async { await AppAccent.save(seed); accentSeed.value = seed; } Future applyAppFont(String fontId) async { if (_fontId == fontId) return; final prefs = await SharedPreferences.getInstance(); await prefs.setString(AppFonts.prefKey, fontId); if (mounted) { setState(() => _fontId = fontId); } } Future applyFontScale(double scale, {bool persist = true}) async { final next = AppFonts.clampScale(scale); fontScale.value = next; if (persist) { final prefs = await SharedPreferences.getInstance(); await prefs.setDouble(AppFonts.scalePrefKey, next); } } void notifyProfileUpdate() { _profileUpdateController.add(null); } String? _themeCacheFontId; ColorScheme? _themeCacheLight; ColorScheme? _themeCacheDark; ThemeData? _lightTheme; ThemeData? _darkTheme; Color? _seedCacheKey; ColorScheme? _seedCacheLight; ColorScheme? _seedCacheDark; ({ColorScheme light, ColorScheme dark}) _schemesForSeed(Color seed) { if (_seedCacheKey == seed && _seedCacheLight != null && _seedCacheDark != null) { return (light: _seedCacheLight!, dark: _seedCacheDark!); } _seedCacheKey = seed; _seedCacheLight = ColorScheme.fromSeed( seedColor: seed, brightness: Brightness.light, ); _seedCacheDark = ColorScheme.fromSeed( seedColor: seed, brightness: Brightness.dark, ); return (light: _seedCacheLight!, dark: _seedCacheDark!); } void _rebuildThemesIfNeeded(ColorScheme light, ColorScheme dark) { if (_themeCacheFontId == _fontId && _themeCacheLight == light && _themeCacheDark == dark) { return; } _themeCacheFontId = _fontId; _themeCacheLight = light; _themeCacheDark = dark; _lightTheme = withM3ETheme( ThemeData( useMaterial3: true, colorScheme: light, textTheme: AppFonts.textTheme( _fontId, ThemeData(brightness: Brightness.light).textTheme, ), ), ); _darkTheme = withM3ETheme( ThemeData( useMaterial3: true, colorScheme: dark, textTheme: AppFonts.textTheme( _fontId, ThemeData(brightness: Brightness.dark).textTheme, ), ), ); } 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), const Color(0xFF0D0D14), ), surfaceContainerHigh: Color.alphaBlend( base.primary.withValues(alpha: 0.08), const Color(0xFF1A1A26), ), surfaceContainerHighest: Color.alphaBlend( base.primary.withValues(alpha: 0.12), const Color(0xFF262636), ), ); } ColorScheme _adjustLightScheme(ColorScheme base) { return base.copyWith( surface: Color.alphaBlend( base.primary.withValues(alpha: 0.06), const Color(0xFFF5F5FA), ), surfaceContainerHigh: Color.alphaBlend( base.primary.withValues(alpha: 0.08), const Color(0xFFEAEAF2), ), surfaceContainerHighest: Color.alphaBlend( base.primary.withValues(alpha: 0.11), const Color(0xFFDEDEE8), ), ); } @override Widget build(BuildContext context) { return DynamicColorBuilder( builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) { return ValueListenableBuilder( valueListenable: accentSeed, builder: (context, seed, _) { final ColorScheme lightBase; final ColorScheme darkBase; if (seed != null) { final s = _schemesForSeed(seed); lightBase = s.light; darkBase = s.dark; } else if (lightDynamic != null && darkDynamic != null) { lightBase = lightDynamic; darkBase = darkDynamic; } else { final s = _schemesForSeed(_fallbackSeed); lightBase = lightDynamic ?? s.light; darkBase = darkDynamic ?? s.dark; } final lightScheme = _adjustLightScheme(lightBase); final darkScheme = _adjustDarkScheme(darkBase); _rebuildThemesIfNeeded(lightScheme, darkScheme); return MaterialApp( title: 'Komet', debugShowCheckedModeBanner: false, locale: _locale, themeMode: _effectiveThemeMode, themeAnimationDuration: Duration.zero, localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, theme: _lightTheme, darkTheme: _darkTheme, navigatorKey: KometApp.navigatorKey, navigatorObservers: [appRouteObserver], builder: (context, child) { return ValueListenableBuilder( valueListenable: fontScale, child: child ?? const SizedBox.shrink(), builder: (context, scale, appChild) { Widget scaledChild = appChild!; if ((scale - 1.0).abs() > 0.001) { scaledChild = MediaQuery.withClampedTextScaling( minScaleFactor: scale, maxScaleFactor: scale, child: scaledChild, ); } return ValueListenableBuilder( valueListenable: fpsOverlayEnabled, child: scaledChild, builder: (context, fpsOn, sChild) { return Stack( fit: StackFit.expand, clipBehavior: Clip.none, children: [ RepaintBoundary( key: _captureBoundaryKey, child: sChild!, ), if (fpsOn) const FpsOverlayLayer(), ], ); }, ); }, ); }, home: const _StartupScreen(), ); }, ); }, ); } } class _StartupScreen extends StatefulWidget { const _StartupScreen(); @override State<_StartupScreen> createState() => _StartupScreenState(); } class _StartupScreenState extends State<_StartupScreen> { @override void initState() { super.initState(); _tryAutoLogin(); } Future _tryAutoLogin() async { unawaited(api.connect()); int? accountId = await TokenStorage.getActiveAccountId(); if (accountId == null || await TokenStorage.readToken(accountId) == null) { accountId = await _recoverActiveAccount(); } if (!mounted) return; if (accountId == null) { _goToLogin(); return; } Navigator.pushReplacement( context, MaterialPageRoute(builder: (_) => const AdaptiveShell()), ); } Future _recoverActiveAccount() async { final profiles = await AppDatabase.loadAllProfiles(); for (final profile in profiles) { if (await TokenStorage.readToken(profile.id) != null) { await TokenStorage.setActiveAccount(profile.id); await AppDatabase.setActiveAccount(profile.id); await ContactsModule.primeCacheFromDb(profile.id); return profile.id; } } return null; } void _goToLogin() { if (mounted) { Navigator.pushReplacement( context, MaterialPageRoute(builder: (_) => const LoginScreen()), ); } } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; return Scaffold( backgroundColor: cs.surface, body: Center( child: CircularProgressIndicator(color: cs.primary, strokeWidth: 2), ), ); } }