Tests / test (push) Canceled after 0s
Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Native crypto / native-crypto (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s
1238 lines
43 KiB
Dart
1238 lines
43 KiB
Dart
import 'core/plugins/plugin_store.dart';
|
|
import 'frontend/debug/performance_monitor.dart';
|
|
import 'dart:async';
|
|
import 'dart:io' show HttpOverrides;
|
|
import 'core/transport/ikar_network.dart';
|
|
import 'dart:math' as math;
|
|
import 'dart:ui' as ui;
|
|
|
|
import 'package:dynamic_color/dynamic_color.dart';
|
|
import 'package:flutter/cupertino.dart' show CupertinoPageTransitionsBuilder;
|
|
import 'package:kusoft/kusoft.dart' show initKusoft;
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart' show HardwareKeyboard;
|
|
import 'package:flutter/rendering.dart';
|
|
import 'package:ikar/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/build_profile.dart';
|
|
import 'core/utils/app_foreground.dart';
|
|
import 'core/utils/logger.dart';
|
|
import 'core/cache/self_presence.dart';
|
|
import 'core/storage/app_instance.dart';
|
|
import 'core/storage/draft_store.dart';
|
|
import 'core/storage/archived_chats_store.dart';
|
|
import 'core/storage/local_contact_avatars.dart';
|
|
import 'core/crypto/e2ee_service.dart';
|
|
import 'core/storage/chat_encryption_store.dart';
|
|
import 'core/config/app_accent.dart';
|
|
import 'core/config/app_amoled.dart';
|
|
import 'core/config/app_show_extra_info.dart';
|
|
import 'core/config/app_spectrum_background.dart';
|
|
import 'core/config/app_bubble_behavior.dart';
|
|
import 'core/config/qlyra_settings.dart';
|
|
import 'core/config/call_no_mute.dart';
|
|
import 'core/config/debug_test.dart';
|
|
import 'core/config/app_bubble_shape.dart';
|
|
import 'core/config/app_cache_extent.dart';
|
|
import 'core/config/app_fonts.dart';
|
|
import 'core/config/custom_font_service.dart';
|
|
import 'core/config/app_message_actions_style.dart';
|
|
import 'core/config/app_message_status_size.dart';
|
|
import 'core/config/app_microphone.dart';
|
|
import 'core/config/app_camera.dart';
|
|
import 'core/security/app_lock.dart';
|
|
import 'frontend/screens/lock/app_lock_layer.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_commands.dart';
|
|
import 'core/config/app_phonebook_names.dart';
|
|
import 'core/contacts/phonebook_contact_sync.dart';
|
|
import 'core/contacts/device_contacts_service.dart';
|
|
import 'core/contacts/startup_contact_names.dart';
|
|
import 'core/config/app_link_preview.dart';
|
|
import 'core/config/app_media_cache.dart';
|
|
import 'core/config/app_video_note_quality.dart';
|
|
import 'core/config/app_pill_gradient.dart';
|
|
import 'core/config/app_visual_style.dart';
|
|
import 'core/config/app_chat_chrome.dart';
|
|
import 'core/config/app_composer_background.dart';
|
|
import 'core/config/app_composer_style.dart';
|
|
import 'core/config/app_nav_pill_style.dart';
|
|
import 'core/config/app_bottom_navigation_style.dart';
|
|
import 'core/config/app_wallpaper_tint.dart';
|
|
import 'core/storage/chat_wallpaper_store.dart';
|
|
import 'core/utils/wallpaper_seed.dart';
|
|
import 'core/utils/seed_scheme.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/chat_admin.dart';
|
|
import 'backend/modules/chats.dart';
|
|
import 'backend/modules/comments.dart';
|
|
import 'backend/modules/contacts.dart';
|
|
import 'backend/modules/file_uploader.dart';
|
|
import 'backend/modules/folders.dart';
|
|
import 'backend/modules/messages.dart';
|
|
import 'backend/modules/outbox.dart';
|
|
import 'backend/modules/polls.dart';
|
|
import 'backend/modules/stickers.dart';
|
|
import 'backend/modules/animoji.dart';
|
|
import 'backend/modules/stories.dart';
|
|
import 'backend/modules/self_check.dart';
|
|
import 'backend/modules/shared_content.dart';
|
|
import 'backend/modules/webapp.dart';
|
|
import 'backend/modules/digital_id.dart';
|
|
import 'core/calls/call_bridge.dart';
|
|
import 'core/calls/call_controller.dart';
|
|
import 'core/media/audio_playback_controller.dart';
|
|
import 'core/media/media_kit_video_platform.dart';
|
|
import 'core/links/deep_link_service.dart';
|
|
import 'frontend/screens/calls/call_screen.dart';
|
|
import 'core/push/fkm_controller.dart';
|
|
import 'core/push/notification_bridge.dart';
|
|
import 'core/share/share_intent_bridge.dart';
|
|
import 'core/push/push_service.dart';
|
|
import 'core/storage/app_database.dart';
|
|
import 'core/transport/tls_config.dart';
|
|
import 'core/transport/traffic_monitor.dart';
|
|
import 'core/transport/vpn_bypass.dart';
|
|
import 'core/storage/token_storage.dart';
|
|
import 'core/storage/startup_account.dart';
|
|
import 'core/utils/haptics.dart';
|
|
import 'core/utils/debug_session_log.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/hint_bubble.dart';
|
|
import 'frontend/widgets/liquid_glass.dart';
|
|
import 'frontend/widgets/theme_reveal.dart';
|
|
import 'frontend/widgets/floating_call_badge.dart';
|
|
import 'frontend/widgets/floating_video_note.dart';
|
|
import 'frontend/widgets/keyboard_dismissal.dart';
|
|
|
|
bool get isOnemeFlavor => BuildProfile.firebasePush;
|
|
|
|
final api = Api();
|
|
final accountModule = AccountModule(api);
|
|
final messagesModule = MessagesModule(api);
|
|
final chatAdminModule = ChatAdminModule(api);
|
|
final commentsModule = CommentsModule(api);
|
|
final sharedContentModule = SharedContentModule(api);
|
|
final pollsModule = PollsModule(api);
|
|
final stickersModule = StickersModule(api);
|
|
final animojiModule = AnimojiModule(api);
|
|
final webAppModule = WebAppModule(api);
|
|
final digitalIdModule = DigitalIdModule(webAppModule);
|
|
final fileUploader = FileUploader(api: api, messages: messagesModule);
|
|
final storiesModule = StoriesModule(api);
|
|
final bannersModule = accountModule.banners;
|
|
final RouteObserver<PageRoute<dynamic>> appRouteObserver =
|
|
RouteObserver<PageRoute<dynamic>>();
|
|
final _performanceRouteObserver = PerfRouteObserver();
|
|
|
|
const ProgressIndicatorThemeData _expressiveProgressTheme =
|
|
// ignore: deprecated_member_use
|
|
ProgressIndicatorThemeData(year2023: false);
|
|
|
|
const PageTransitionsTheme _appPageTransitions = PageTransitionsTheme(
|
|
builders: <TargetPlatform, PageTransitionsBuilder>{
|
|
TargetPlatform.android: PredictiveBackPageTransitionsBuilder(),
|
|
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
|
|
TargetPlatform.macOS: CupertinoPageTransitionsBuilder(),
|
|
TargetPlatform.windows: ZoomPageTransitionsBuilder(),
|
|
TargetPlatform.linux: ZoomPageTransitionsBuilder(),
|
|
},
|
|
);
|
|
|
|
Future<Locale> _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 _installLogCapture() {
|
|
final previousDebugPrint = debugPrint;
|
|
debugPrint = (String? message, {int? wrapWidth}) {
|
|
if (message != null) {
|
|
final t = DateTime.now();
|
|
final stamp =
|
|
'${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}:${t.second.toString().padLeft(2, '0')}.${t.millisecond.toString().padLeft(3, '0')}';
|
|
DebugSessionLog.instance.recordLogLine(' |$stamp P $message');
|
|
}
|
|
previousDebugPrint(message, wrapWidth: wrapWidth);
|
|
};
|
|
|
|
final previousFlutterOnError = FlutterError.onError;
|
|
FlutterError.onError = (FlutterErrorDetails details) {
|
|
DebugSessionLog.instance.recordLogLine(
|
|
' | FlutterError: ${details.exceptionAsString()}',
|
|
);
|
|
if (details.stack != null) {
|
|
DebugSessionLog.instance.recordLogLine(details.stack.toString());
|
|
}
|
|
if (previousFlutterOnError != null) {
|
|
previousFlutterOnError(details);
|
|
} else {
|
|
FlutterError.presentError(details);
|
|
}
|
|
};
|
|
|
|
final previousPlatformOnError = ui.PlatformDispatcher.instance.onError;
|
|
ui.PlatformDispatcher.instance.onError = (Object error, StackTrace stack) {
|
|
DebugSessionLog.instance.recordLogLine(' | Uncaught: $error');
|
|
DebugSessionLog.instance.recordLogLine(stack.toString());
|
|
if (previousPlatformOnError != null) {
|
|
return previousPlatformOnError(error, stack);
|
|
}
|
|
return false;
|
|
};
|
|
}
|
|
|
|
void main(List<String> args) async {
|
|
HttpOverrides.global = IkarHttpOverrides();
|
|
WidgetsFlutterBinding.ensureInitialized();
|
|
trackHintBubblePresses();
|
|
DebugTest.parse(args);
|
|
CallNoMute.parse(args);
|
|
_installLogCapture();
|
|
MediaKitVideoPlatform.registerOnDesktop();
|
|
if (AppInstance.isNamed) {
|
|
SharedPreferences.setPrefix('flutter.${AppInstance.id}.');
|
|
}
|
|
// Start independent local preparation together. Network and optional services
|
|
// must not hold the first screen behind a splash/loading route.
|
|
final transportReady = initKusoft().then(
|
|
(_) => TlsConfig.applyMincifryTrust(),
|
|
);
|
|
final databaseReady = AppDatabase.init();
|
|
final accountReady = databaseReady.then(
|
|
(_) => resolveStartupAccount(
|
|
readActiveId: TokenStorage.getActiveAccountId,
|
|
readToken: TokenStorage.readToken,
|
|
readProfileIds: () async =>
|
|
(await AppDatabase.loadAllProfiles()).map((p) => p.id).toList(),
|
|
activate: (id) async {
|
|
await TokenStorage.setActiveAccount(id);
|
|
await AppDatabase.setActiveAccount(id);
|
|
},
|
|
),
|
|
);
|
|
attachInfoCacheApi(api);
|
|
chats.attachGlobalPushHandlers(api);
|
|
FoldersModule.attachGlobalPushHandlers(api);
|
|
TranscriptionPushHandler.attach(api);
|
|
commentsModule.attachPushHandlers(api);
|
|
storiesModule.attach();
|
|
|
|
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 pillGradientFuture = AppPillGradient.load();
|
|
final visualStyleFuture = AppVisualStyle.load();
|
|
final liquidGlassFuture = LiquidGlass.load();
|
|
final chatChromeFuture = AppChatChrome.load();
|
|
final composerStyleFuture = AppComposerStyle.load();
|
|
final composerBackgroundFuture = AppComposerBackground.load();
|
|
final navPillStyleFuture = AppNavPillStyle.load();
|
|
await AppBottomNavigationStyle.load();
|
|
final wallpaperTintFuture = AppWallpaperTint.load();
|
|
final themeScheduleFuture = AppThemeSchedule.load();
|
|
final messageActionsFuture = AppMessageActionsStyle.load();
|
|
final messageStatusSizeFuture = AppMessageStatusSize.load();
|
|
final swipeBackFuture = AppSwipeBackDesktop.load();
|
|
final microphoneFuture = AppMicrophone.load();
|
|
final cameraFuture = AppCamera.load();
|
|
final videoNoteCameraFuture = AppVideoNoteCamera.load();
|
|
final pranksFuture = AppPranks.load();
|
|
final storiesFuture = AppStories.load();
|
|
final commandsFuture = AppCommands.load();
|
|
final phonebookNamesFuture = AppPhonebookNames.load();
|
|
final contactCacheFuture = ContactCache.load();
|
|
final contactNamesReady = prepareStartupContactNames(
|
|
accountReady: accountReady,
|
|
settingsReady: phonebookNamesFuture.then((_) {}),
|
|
cacheReady: contactCacheFuture,
|
|
primeContacts: ContactsModule.primeCacheFromDb,
|
|
loadPhonebook: DeviceContactsService.loadFromStartup,
|
|
onError: (source, error, stack) =>
|
|
logger.e('Startup $source failed: $error', stackTrace: stack),
|
|
);
|
|
final linkPreviewFuture = AppLinkPreview.load();
|
|
final cacheLimitFuture = AppMediaCacheLimit.load();
|
|
final videoNoteResolutionFuture = AppVideoNoteResolution.load();
|
|
final videoNoteFpsFuture = AppVideoNoteFps.load();
|
|
final videoNoteRearCameraFuture = AppVideoNoteRearCamera.load();
|
|
final digitalIdNativeFuture = AppDigitalIdNative.load();
|
|
final showExtraInfoFuture = AppShowExtraInfo.load();
|
|
final spectrumBackgroundFuture = AppSpectrumBackground.load();
|
|
await LocalContactAvatars.instance.load();
|
|
final trafficCaptureFuture = TrafficMonitor.instance.load();
|
|
final debugLogFuture = DebugSessionLog.instance.init();
|
|
|
|
await packageInfoFuture;
|
|
|
|
final initialLocale = await localeFuture;
|
|
|
|
await hapticsFuture;
|
|
|
|
final prefs = await prefsFuture;
|
|
await Future.wait<void>([
|
|
FileHistoryCache.load(prefs),
|
|
DraftStore.instance.load(),
|
|
ArchivedChatsStore.instance.load(),
|
|
ChatEncryptionStore.instance.load(),
|
|
QlyraSettings.load(),
|
|
AppLock.instance.load(),
|
|
contactCacheFuture,
|
|
]);
|
|
E2eeService.instance.attach(messagesModule);
|
|
if (QlyraSettings.ghostMode.value) SelfPresence.markOffline();
|
|
final initialFpsOverlay = prefs.getBool('dev_fps_overlay') ?? false;
|
|
final initialVpnBypass =
|
|
BuildProfile.insecureTransport &&
|
|
(prefs.getBool(VpnBypassService.prefKey) ?? false);
|
|
final initialTlsInsecure =
|
|
BuildProfile.insecureTransport &&
|
|
(prefs.getBool(TlsConfig.prefKey) ?? false);
|
|
final initialFontId =
|
|
prefs.getString(AppFonts.prefKey) ?? AppFonts.fallback.id;
|
|
final initialFontScale = AppFonts.clampScale(
|
|
prefs.getDouble(AppFonts.scalePrefKey) ?? AppFonts.defaultScale,
|
|
);
|
|
if (AppFonts.resolve(initialFontId).isCustom) {
|
|
await CustomFontService.preloadCached();
|
|
} else {
|
|
unawaited(CustomFontService.preloadCached());
|
|
}
|
|
final initialAccentSeed = await accentFuture;
|
|
await Future.wait<dynamic>([
|
|
bubbleShapeFuture,
|
|
bubbleBehaviorFuture,
|
|
cacheExtentFuture,
|
|
themeModeFuture,
|
|
amoledFuture,
|
|
pillGradientFuture,
|
|
visualStyleFuture,
|
|
liquidGlassFuture,
|
|
chatChromeFuture,
|
|
composerStyleFuture,
|
|
composerBackgroundFuture,
|
|
navPillStyleFuture,
|
|
wallpaperTintFuture,
|
|
themeScheduleFuture,
|
|
messageActionsFuture,
|
|
messageStatusSizeFuture,
|
|
swipeBackFuture,
|
|
microphoneFuture,
|
|
cameraFuture,
|
|
videoNoteCameraFuture,
|
|
pranksFuture,
|
|
storiesFuture,
|
|
commandsFuture,
|
|
phonebookNamesFuture,
|
|
linkPreviewFuture,
|
|
cacheLimitFuture,
|
|
videoNoteResolutionFuture,
|
|
videoNoteFpsFuture,
|
|
videoNoteRearCameraFuture,
|
|
digitalIdNativeFuture,
|
|
showExtraInfoFuture,
|
|
spectrumBackgroundFuture,
|
|
]);
|
|
final activeAccountId = await accountReady;
|
|
await transportReady;
|
|
await trafficCaptureFuture;
|
|
await debugLogFuture;
|
|
await contactNamesReady;
|
|
runApp(
|
|
QlyraApp(
|
|
initialHasAccount: DebugTest.enabled || activeAccountId != null,
|
|
initialLocale: initialLocale,
|
|
initialFpsOverlay: initialFpsOverlay,
|
|
initialVpnBypass: initialVpnBypass,
|
|
initialTlsInsecure: initialTlsInsecure,
|
|
initialFontId: initialFontId,
|
|
initialFontScale: initialFontScale,
|
|
initialAccentSeed: initialAccentSeed,
|
|
),
|
|
);
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
unawaited(_loadOptionalStartupServices());
|
|
});
|
|
}
|
|
|
|
Future<void> _loadOptionalStartupServices() async {
|
|
Future<void> attempt(String name, Future<void> Function() load) async {
|
|
try {
|
|
await load();
|
|
} catch (e, stack) {
|
|
logger.e('Startup $name failed: $e', stackTrace: stack);
|
|
}
|
|
}
|
|
|
|
await Future.wait<void>([
|
|
attempt('plugins', PluginStore.instance.load),
|
|
attempt('push', () => FkmController.instance.init(api)),
|
|
attempt('stories', storiesModule.loadCache),
|
|
attempt('links', DeepLinkService.instance.init),
|
|
]);
|
|
}
|
|
|
|
class QlyraApp extends StatefulWidget {
|
|
const QlyraApp({
|
|
super.key,
|
|
required this.initialLocale,
|
|
this.initialFpsOverlay = false,
|
|
this.initialVpnBypass = false,
|
|
this.initialTlsInsecure = false,
|
|
required this.initialFontId,
|
|
required this.initialFontScale,
|
|
this.initialAccentSeed,
|
|
this.initialHasAccount = false,
|
|
});
|
|
|
|
final Locale initialLocale;
|
|
final bool initialFpsOverlay;
|
|
final bool initialVpnBypass;
|
|
final bool initialTlsInsecure;
|
|
final String initialFontId;
|
|
final double initialFontScale;
|
|
final Color? initialAccentSeed;
|
|
final bool initialHasAccount;
|
|
static final navigatorKey = GlobalKey<NavigatorState>();
|
|
|
|
static QlyraAppState? stateOf(BuildContext context) {
|
|
return context.findAncestorStateOfType<QlyraAppState>();
|
|
}
|
|
|
|
@override
|
|
State<QlyraApp> createState() => QlyraAppState();
|
|
}
|
|
|
|
class QlyraAppState extends State<QlyraApp>
|
|
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;
|
|
bool _shellReady = false;
|
|
bool _incomingRouteActive = false;
|
|
IncomingCall? _pendingIncoming;
|
|
late final ValueNotifier<Color?> accentSeed = ValueNotifier(
|
|
widget.initialAccentSeed,
|
|
);
|
|
final ValueNotifier<Color?> wallpaperSeed = ValueNotifier(null);
|
|
StreamSubscription<SessionExpiredException>? _sessionExpiredSub;
|
|
StreamSubscription<LoginStatus>? _loginStatusSub;
|
|
final Set<int> _phonebookSyncEpochs = {};
|
|
StreamSubscription<VpnBypassResult>? _vpnBypassSub;
|
|
StreamSubscription<IncomingCall>? _callIncomingSub;
|
|
StreamSubscription<String>? _serverErrorSub;
|
|
StreamSubscription<AccountNotice>? _accountNoticeSub;
|
|
Timer? _scheduleTimer;
|
|
String? _lastVpnNotice;
|
|
DateTime _lastVpnNoticeAt = DateTime.fromMillisecondsSinceEpoch(0);
|
|
String? _lastServerError;
|
|
DateTime _lastServerErrorAt = DateTime.fromMillisecondsSinceEpoch(0);
|
|
late final ValueNotifier<bool> fpsOverlayEnabled = ValueNotifier(
|
|
widget.initialFpsOverlay,
|
|
);
|
|
late final ValueNotifier<bool> vpnBypassEnabled = ValueNotifier(
|
|
widget.initialVpnBypass,
|
|
);
|
|
late final ValueNotifier<bool> tlsInsecureEnabled = ValueNotifier(
|
|
widget.initialTlsInsecure,
|
|
);
|
|
late final ValueNotifier<double> fontScale = ValueNotifier(
|
|
widget.initialFontScale,
|
|
);
|
|
final _profileUpdateController = StreamController<void>.broadcast();
|
|
Stream<void> get profileUpdateStream => _profileUpdateController.stream;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_locale = widget.initialLocale;
|
|
_fontId = widget.initialFontId;
|
|
|
|
WidgetsBinding.instance.addObserver(this);
|
|
HardwareKeyboard.instance.addHandler(_noteKeyInteraction);
|
|
AudioPlaybackController.error.addListener(_onAudioPlaybackError);
|
|
AppThemeModeConfig.current.addListener(_onThemeModeChanged);
|
|
AppAmoled.current.addListener(_onAmoledChanged);
|
|
AppThemeSchedule.current.addListener(_onScheduleChanged);
|
|
AppWallpaperTint.current.addListener(_onWallpaperTintChanged);
|
|
ChatWallpaperStore.instance.revision.addListener(_onWallpaperTintChanged);
|
|
_lastAppliedThemeMode = _effectiveThemeMode;
|
|
_rescheduleSwitch();
|
|
unawaited(_refreshWallpaperSeed());
|
|
|
|
OutboxService.instance.init(
|
|
api,
|
|
messagesModule,
|
|
isAuthenticated: () => accountModule.isLoggedIn,
|
|
);
|
|
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 (e) {
|
|
logger.w('reconnect login failed: $e');
|
|
}
|
|
});
|
|
|
|
_loginStatusSub = accountModule.loginStatusStream.listen((status) async {
|
|
if (status == LoginStatus.success) {
|
|
unawaited(_syncPhonebookContacts());
|
|
DeepLinkService.instance.markReady();
|
|
NotificationBridge.instance.markReady();
|
|
unawaited(NotificationBridge.instance.dismissReadChats());
|
|
ShareIntentBridge.instance.markReady();
|
|
unawaited(_refreshWallpaperSeed());
|
|
CallController.instance.init(api);
|
|
OutboxService.instance.init(
|
|
api,
|
|
messagesModule,
|
|
isAuthenticated: () => accountModule.isLoggedIn,
|
|
);
|
|
SelfCheckService.instance.init(api);
|
|
SelfCheckService.instance.checkNow();
|
|
if (BuildProfile.firebasePush) {
|
|
await PushService.instance.init(api: api, account: accountModule);
|
|
await PushService.instance.onLoginSuccess();
|
|
}
|
|
}
|
|
});
|
|
|
|
_callIncomingSub = CallController.instance.incomingCalls.listen(
|
|
_onIncomingCall,
|
|
);
|
|
CallController.instance.appResumed = true;
|
|
CallBridge.instance.init();
|
|
NotificationBridge.instance.init();
|
|
ShareIntentBridge.instance.init();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
markShellReady();
|
|
if (!DebugTest.enabled) unawaited(api.connect());
|
|
CallBridge.instance.checkInitialCall();
|
|
unawaited(NotificationBridge.instance.checkInitialChat());
|
|
unawaited(ShareIntentBridge.instance.checkInitialShare());
|
|
});
|
|
|
|
_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 = QlyraApp.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'}'
|
|
: 'Обход 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 = QlyraApp.navigatorKey.currentState?.overlay;
|
|
if (overlay != null) {
|
|
showCustomNotificationOnOverlay(overlay, msg);
|
|
}
|
|
});
|
|
|
|
_serverErrorSub = api.errorStream.listen((msg) {
|
|
if (api.state != SessionState.online) return;
|
|
final now = DateTime.now();
|
|
if (msg == _lastServerError &&
|
|
now.difference(_lastServerErrorAt).inSeconds < 3) {
|
|
return;
|
|
}
|
|
_lastServerError = msg;
|
|
_lastServerErrorAt = now;
|
|
|
|
final overlay = QlyraApp.navigatorKey.currentState?.overlay;
|
|
if (overlay != null) {
|
|
showCustomNotificationOnOverlay(overlay, msg);
|
|
}
|
|
});
|
|
|
|
_accountNoticeSub = accountModule.noticeStream.listen((notice) {
|
|
final overlay = QlyraApp.navigatorKey.currentState?.overlay;
|
|
final ctx = QlyraApp.navigatorKey.currentContext;
|
|
if (overlay == null || ctx == null || !ctx.mounted) return;
|
|
final l10n = AppLocalizations.of(ctx);
|
|
if (l10n == null) return;
|
|
final message = switch (notice) {
|
|
AccountNotice.resurrectingProfile => l10n.profileResurrecting,
|
|
};
|
|
showCustomNotificationOnOverlay(overlay, message);
|
|
});
|
|
}
|
|
|
|
void _onIncomingCall(IncomingCall call) {
|
|
_pendingIncoming = call;
|
|
_presentIncomingCall();
|
|
}
|
|
|
|
void markShellReady() {
|
|
if (_shellReady) return;
|
|
_shellReady = true;
|
|
_presentIncomingCall();
|
|
}
|
|
|
|
void _presentIncomingCall() {
|
|
final call = _pendingIncoming;
|
|
if (call == null || _incomingRouteActive || !_shellReady) return;
|
|
final navState = QlyraApp.navigatorKey.currentState;
|
|
if (navState == null) {
|
|
WidgetsBinding.instance.addPostFrameCallback(
|
|
(_) => _presentIncomingCall(),
|
|
);
|
|
return;
|
|
}
|
|
_incomingRouteActive = true;
|
|
navState
|
|
.push(
|
|
MaterialPageRoute(
|
|
builder: (_) => CallScreen(
|
|
name: ContactCache.get(call.callerId) ?? call.callerName ?? '',
|
|
avatarUrl: ContactCache.getAvatar(call.callerId),
|
|
incoming: call,
|
|
autoAccept: call.autoAccept,
|
|
),
|
|
),
|
|
)
|
|
.whenComplete(() {
|
|
_incomingRouteActive = false;
|
|
if (identical(_pendingIncoming, call)) _pendingIncoming = null;
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_finishReveal();
|
|
_sessionExpiredSub?.cancel();
|
|
_loginStatusSub?.cancel();
|
|
unawaited(OutboxService.instance.dispose());
|
|
_vpnBypassSub?.cancel();
|
|
_callIncomingSub?.cancel();
|
|
_serverErrorSub?.cancel();
|
|
_accountNoticeSub?.cancel();
|
|
_scheduleTimer?.cancel();
|
|
AppThemeModeConfig.current.removeListener(_onThemeModeChanged);
|
|
AppAmoled.current.removeListener(_onAmoledChanged);
|
|
AppThemeSchedule.current.removeListener(_onScheduleChanged);
|
|
AppWallpaperTint.current.removeListener(_onWallpaperTintChanged);
|
|
ChatWallpaperStore.instance.revision.removeListener(
|
|
_onWallpaperTintChanged,
|
|
);
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
HardwareKeyboard.instance.removeHandler(_noteKeyInteraction);
|
|
AudioPlaybackController.error.removeListener(_onAudioPlaybackError);
|
|
_profileUpdateController.close();
|
|
fpsOverlayEnabled.dispose();
|
|
vpnBypassEnabled.dispose();
|
|
tlsInsecureEnabled.dispose();
|
|
fontScale.dispose();
|
|
accentSeed.dispose();
|
|
wallpaperSeed.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _onAudioPlaybackError() {
|
|
final message = AudioPlaybackController.error.value;
|
|
if (message == null) return;
|
|
AudioPlaybackController.error.value = null;
|
|
final overlay = QlyraApp.navigatorKey.currentState?.overlay;
|
|
if (overlay == null) return;
|
|
final text = message.isEmpty
|
|
? AppLocalizations.of(overlay.context)?.audioPlaybackFailed
|
|
: message;
|
|
if (text == null || text.isEmpty) return;
|
|
showCustomNotificationOnOverlay(overlay, text);
|
|
}
|
|
|
|
bool _noteKeyInteraction(KeyEvent event) {
|
|
AppLock.instance.noteInteraction();
|
|
return false;
|
|
}
|
|
|
|
@override
|
|
Future<bool> didPopRoute() async => AppLock.instance.locked.value;
|
|
|
|
@override
|
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
AppLock.instance.onLifecycle(state);
|
|
final background =
|
|
state == AppLifecycleState.paused ||
|
|
state == AppLifecycleState.hidden ||
|
|
state == AppLifecycleState.detached;
|
|
AppForeground.update(foreground: !background);
|
|
CallController.instance.appResumed = state == AppLifecycleState.resumed;
|
|
if (state == AppLifecycleState.inactive && CallController.instance.isBusy) {
|
|
unawaited(CallBridge.instance.ensureOngoing());
|
|
}
|
|
if (background) {
|
|
DebugSessionLog.instance.flushNow();
|
|
SelfCheckService.instance.pause();
|
|
E2eeService.instance.lock();
|
|
}
|
|
if (state != AppLifecycleState.resumed) return;
|
|
api.wakeUp();
|
|
unawaited(_syncPhonebookContacts());
|
|
SelfCheckService.instance.resume();
|
|
if (!CallController.instance.isBusy) {
|
|
unawaited(CallBridge.instance.dropOngoing());
|
|
}
|
|
CallBridge.instance.checkInitialCall();
|
|
unawaited(NotificationBridge.instance.onAppResumed());
|
|
unawaited(NotificationBridge.instance.checkInitialChat());
|
|
unawaited(ShareIntentBridge.instance.checkInitialShare());
|
|
if (AppThemeModeConfig.current.value != AppThemeMode.schedule) return;
|
|
_rescheduleSwitch();
|
|
final next = _effectiveThemeMode;
|
|
if (next == _lastAppliedThemeMode) return;
|
|
_lastAppliedThemeMode = next;
|
|
if (mounted) setState(() {});
|
|
}
|
|
|
|
Future<void> _syncPhonebookContacts() async {
|
|
if (!mounted || !accountModule.isLoggedIn || DebugTest.enabled) return;
|
|
final epoch = api.sessionEpoch;
|
|
if (!_phonebookSyncEpochs.add(epoch)) return;
|
|
bool isCurrent() =>
|
|
mounted && accountModule.isLoggedIn && api.sessionEpoch == epoch;
|
|
try {
|
|
final profile = await AppDatabase.loadActiveProfile();
|
|
if (profile == null || !isCurrent()) return;
|
|
final loaded = await DeviceContactsService.ensureLoadedInteractive(
|
|
forContacts: true,
|
|
refresh: true,
|
|
);
|
|
if (!loaded || !isCurrent()) return;
|
|
final phonebook = DeviceContactsService.phonebook;
|
|
if (phonebook.isEmpty) return;
|
|
await ContactsModule.syncFromServer(api, profile.id);
|
|
if (!isCurrent()) return;
|
|
final contacts = await ContactsModule.getContacts(profile.id);
|
|
if (!isCurrent()) return;
|
|
final added = await PhonebookContactSync.importContacts(
|
|
phonebook: phonebook,
|
|
contacts: contacts,
|
|
accountId: profile.id,
|
|
lookup: (phone) =>
|
|
ContactsModule.findByPhone(api, phone, silent: true, strict: true),
|
|
add: (phone, name) => ContactsModule.addContactByPhone(
|
|
api,
|
|
phone: phone,
|
|
firstName: name,
|
|
expectedAccountId: profile.id,
|
|
),
|
|
isCurrent: isCurrent,
|
|
);
|
|
if (added > 0) logger.i('Телефонная книга: добавлено в MAX $added');
|
|
} catch (_) {
|
|
logger.w('Телефонная книга: синхронизация MAX не завершена');
|
|
} finally {
|
|
_phonebookSyncEpochs.remove(epoch);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
void applyThemeModeWithReveal(AppThemeMode mode, Offset center) {
|
|
if (AppThemeModeConfig.current.value == mode) return;
|
|
_runThemeReveal(center, () => AppThemeModeConfig.save(mode));
|
|
}
|
|
|
|
Future<void> 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<void> Function() apply) {
|
|
final overlay = QlyraApp.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<void> applyThemeSchedule(ThemeSchedule schedule) async {
|
|
await AppThemeSchedule.save(schedule);
|
|
}
|
|
|
|
Future<void> 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<void> setVpnBypassEnabled(bool value) async {
|
|
if (vpnBypassEnabled.value == value) return;
|
|
vpnBypassEnabled.value = value;
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setBool(VpnBypassService.prefKey, value);
|
|
}
|
|
|
|
Future<void> setTlsInsecureEnabled(bool value) async {
|
|
if (tlsInsecureEnabled.value == value) return;
|
|
tlsInsecureEnabled.value = value;
|
|
await TlsConfig.setInsecureAllowed(value);
|
|
}
|
|
|
|
Future<void> 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<void> applyAccentColor(Color? seed) async {
|
|
await AppAccent.save(seed);
|
|
accentSeed.value = seed;
|
|
}
|
|
|
|
void _onWallpaperTintChanged() => unawaited(_refreshWallpaperSeed());
|
|
|
|
Future<void> _refreshWallpaperSeed() async {
|
|
if (!AppWallpaperTint.current.value) {
|
|
wallpaperSeed.value = null;
|
|
return;
|
|
}
|
|
final profile = await AppDatabase.loadActiveProfile();
|
|
final accountId = profile?.id ?? 0;
|
|
if (accountId == 0) {
|
|
wallpaperSeed.value = null;
|
|
return;
|
|
}
|
|
await ChatWallpaperStore.instance.load();
|
|
final wallpaper = ChatWallpaperStore.instance.get(
|
|
accountId,
|
|
kGlobalWallpaperChatId,
|
|
);
|
|
final seed = await computeWallpaperSeed(wallpaper);
|
|
if (!mounted) return;
|
|
wallpaperSeed.value = seed;
|
|
}
|
|
|
|
Future<void> 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<void> 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 = schemeFromSeed(seed, Brightness.light);
|
|
_seedCacheDark = schemeFromSeed(seed, 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;
|
|
final displayFont = AppDisplayFont(AppFonts.displayFamily(_fontId));
|
|
_lightTheme = withM3ETheme(
|
|
ThemeData(
|
|
useMaterial3: true,
|
|
colorScheme: light,
|
|
pageTransitionsTheme: _appPageTransitions,
|
|
progressIndicatorTheme: _expressiveProgressTheme,
|
|
tooltipTheme: HintBubbleStyle.tooltipTheme(light),
|
|
extensions: [displayFont],
|
|
textTheme: AppFonts.textTheme(
|
|
_fontId,
|
|
ThemeData(brightness: Brightness.light).textTheme,
|
|
),
|
|
),
|
|
);
|
|
_darkTheme = withM3ETheme(
|
|
ThemeData(
|
|
useMaterial3: true,
|
|
colorScheme: dark,
|
|
pageTransitionsTheme: _appPageTransitions,
|
|
progressIndicatorTheme: _expressiveProgressTheme,
|
|
tooltipTheme: HintBubbleStyle.tooltipTheme(dark),
|
|
extensions: [displayFont],
|
|
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 ListenableBuilder(
|
|
listenable: Listenable.merge([
|
|
accentSeed,
|
|
wallpaperSeed,
|
|
AppWallpaperTint.current,
|
|
]),
|
|
builder: (context, _) {
|
|
final seed =
|
|
AppWallpaperTint.current.value && wallpaperSeed.value != null
|
|
? wallpaperSeed.value
|
|
: accentSeed.value;
|
|
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: 'ИКАР',
|
|
debugShowCheckedModeBanner: false,
|
|
locale: _locale,
|
|
themeMode: _effectiveThemeMode,
|
|
themeAnimationDuration: Duration.zero,
|
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
|
supportedLocales: AppLocalizations.supportedLocales,
|
|
theme: _lightTheme,
|
|
darkTheme: _darkTheme,
|
|
navigatorKey: QlyraApp.navigatorKey,
|
|
navigatorObservers: [
|
|
appRouteObserver,
|
|
_performanceRouteObserver,
|
|
KeyboardNavigatorObserver(),
|
|
],
|
|
builder: (context, child) {
|
|
return ValueListenableBuilder<double>(
|
|
valueListenable: fontScale,
|
|
child: child ?? const SizedBox.shrink(),
|
|
builder: (context, scale, appChild) {
|
|
Widget scaledChild = appChild!;
|
|
final effective = AppFonts.effectiveScale(_fontId, scale);
|
|
if ((effective - 1.0).abs() > 0.001) {
|
|
scaledChild = MediaQuery.withClampedTextScaling(
|
|
minScaleFactor: effective,
|
|
maxScaleFactor: effective,
|
|
child: scaledChild,
|
|
);
|
|
}
|
|
return ValueListenableBuilder<bool>(
|
|
valueListenable: fpsOverlayEnabled,
|
|
child: scaledChild,
|
|
builder: (context, fpsOn, sChild) {
|
|
return Listener(
|
|
onPointerDown: (_) =>
|
|
AppLock.instance.noteInteraction(),
|
|
child: AppLockLayer(
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
RepaintBoundary(
|
|
key: _captureBoundaryKey,
|
|
child: KeyboardDismissal(child: sChild!),
|
|
),
|
|
const Positioned.fill(
|
|
child: FloatingVideoNoteLayer(),
|
|
),
|
|
const Positioned.fill(
|
|
child: FloatingCallBadgeLayer(),
|
|
),
|
|
if (fpsOn) const FpsOverlayLayer(),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
},
|
|
home: widget.initialHasAccount
|
|
? const AdaptiveShell()
|
|
: const LoginScreen(),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|