import 'dart:async'; import 'dart:developer'; import 'dart:io'; import 'dart:ui'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:jiffy/jiffy.dart'; import 'package:loader_overlay/loader_overlay.dart'; import 'package:path_provider/path_provider.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'api/marianumcloud/webdav/queries/list_files/list_files_cache.dart'; import 'api/marianumconnect/auth/session_validator.dart'; import 'api/marianumconnect/marianumconnect_endpoint.dart'; import 'api/marianumconnect/queries/get_breakers/get_breakers_response.dart'; import 'api/marianumconnect/queries/report_client_error/client_error_reporter.dart'; import 'api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart'; import 'app.dart'; import 'auth_link/guardian_link_listener.dart'; import 'background/widget_background_task.dart'; import 'firebase_options.dart'; import 'notification/notification_service.dart'; import 'push/notification_permission_prompt.dart'; import 'push/push_message_handler.dart'; import 'push/push_registration.dart'; import 'push/push_registration_store.dart'; import 'push/push_renderer.dart'; import 'routing/app_routes.dart'; import 'session/session_manager.dart'; import 'share_intent/share_intent_listener.dart'; import 'state/app/modules/account/bloc/account_bloc.dart'; import 'state/app/modules/account/bloc/account_state.dart'; import 'state/app/modules/breaker/bloc/breaker_bloc.dart'; import 'state/app/modules/capabilities/bloc/capabilities_cubit.dart'; import 'state/app/modules/chat/bloc/chat_bloc.dart'; import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import 'state/app/modules/children/child_selection_cubit.dart'; import 'state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart'; import 'state/app/modules/parent_letters/bloc/parent_letters_bloc.dart'; import 'state/app/modules/settings/bloc/settings_cubit.dart'; import 'state/app/modules/timetable/primary/primary_timetable_scope.dart'; import 'storage/hydrated_storage_bootstrap.dart'; import 'storage/settings.dart'; import 'theming/dark_app_theme.dart'; import 'theming/light_app_theme.dart'; import 'utils/app_paths.dart'; import 'utils/debouncer.dart'; import 'utils/downloads/download_manager.dart'; import 'view/login/account_loading_screen.dart'; import 'view/login/login.dart'; import 'view/login/post_login_splash.dart'; import 'widget/avatar_disk_cache.dart'; import 'widget/breaker/breaker.dart'; import 'widget/debug/cache_view.dart'; import 'widget/downloads/download_tray.dart'; import 'widget/emergency/emergency_notice_gate.dart'; import 'widget_data/widget_sync.dart'; /// Runs one startup step with a time limit. Anything that throws or hangs /// before `runApp` would otherwise leave the native launch screen up for /// good, so failures are logged and reported and the app starts degraded. Future _startupStep( String name, Future Function() step, { Duration? timeout = const Duration(seconds: 10), bool report = true, }) async { try { final future = step(); await (timeout == null ? future : future.timeout(timeout)); } catch (e, s) { log('Startup step "$name" failed: $e', stackTrace: s); if (report) { ClientErrorReporter.reportPlatformError('Startup step "$name": $e', s); } } } void _installErrorHandlers() { if (kReleaseMode) { ErrorWidget.builder = (error) => Material( color: Colors.white, child: Center( child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.phonelink_erase_rounded, size: 40), const SizedBox(height: 12), Text(error.toStringShort(), textAlign: TextAlign.center), ], ), ), ), ); } FlutterError.onError = (details) { log( 'Uncaught Flutter error: ${details.exception}', stackTrace: details.stack, ); ClientErrorReporter.reportFlutterError(details); FlutterError.presentError(details); }; PlatformDispatcher.instance.onError = (error, stack) { log('Uncaught platform error: $error', stackTrace: stack); ClientErrorReporter.reportPlatformError(error, stack); return true; }; } Future main() async { log('MarianumMobile started'); WidgetsFlutterBinding.ensureInitialized(); // Before any initialisation so startup failures reach the backend too. _installErrorHandlers(); Future trustCertificate(String asset) => PlatformAssetBundle() .load(asset) .then( (certificate) => SecurityContext.defaultContext .setTrustedCertificatesBytes(certificate.buffer.asUint8List()), ); final initialisationTasks = [ _startupStep( 'firebase', () => Firebase.initializeApp( options: DefaultFirebaseOptions.currentPlatform, ), ), _startupStep( 'ca certificates', () => Future.wait([ trustCertificate('assets/ca/lets-encrypt-r3.pem'), trustCertificate('assets/ca/lets-encrypt-r10.pem'), trustCertificate('assets/ca/lets-encrypt-r13.pem'), ]), ), _startupStep('hydrated storage', () async { final path = (await getTemporaryDirectory()).path; HydratedBloc.storage = await buildHydratedStorageWithFallback(path); }, timeout: null), _startupStep('documents dir', () async { AppPaths.documentsDir = (await getApplicationDocumentsDirectory()).path; }), // The keychain may still be locked right after device unlock; the session // keeps retrying, so on timeout the app starts on the loading screen and // flips to the real state once the session is readable (see _MainState). _startupStep( 'account data', SessionManager().waitForLoad, timeout: const Duration(seconds: 5), // Expected on every background wake of a locked device; not an error. report: false, ), _startupStep('share intent', ShareIntentListener.instance.initialize), _startupStep('guardian link', GuardianLinkListener.instance.initialize), ]; log('starting app initialisation...'); await Future.wait(initialisationTasks); log('app initialisation done!'); // Local notifications: init the plugin (with tap/action callbacks) and the // Android channels, then register the FCM background isolate handler that // decrypts and renders Nextcloud pushes while the app is not in foreground. await _startupStep( 'notifications', NotificationService().initializeNotifications, ); await _startupStep('notification channels', PushRenderer.ensureChannels); FirebaseMessaging.onBackgroundMessage(pushOnBackgroundMessage); // Wire up the native background downloader (progress notifications + tap // handling) before the UI so a completion notification tapped during cold // start is captured and opened once the downloads tray mounts. unawaited( DownloadManager.instance.initialize().onError( (e, _) => log('DownloadManager init failed: $e'), ), ); // Wire up the home-screen widget bridge before runApp so any widget render // triggered during startup hits initialised native storage. await _startupStep('widget sync', WidgetSync.ensureInitialized); unawaited( WidgetBackgroundTask.initialize().onError( (e, _) => log('Workmanager init failed: $e'), ), ); // Diagnostic log only. getToken() can fail transiently during cold start // (Android: "IOException: FCM Registration failed!" from Play Services, // iOS: apns-token-not-set until APNS registration completes), so tolerate // the failure instead of letting it surface as an uncaught async error that // gets reported to the telemetry backend as noise. unawaited( FirebaseMessaging.instance .getToken() .then( (token) => log('Firebase token: ${token ?? "Error: no Firebase token!"}'), ) .onError((e, _) => log('Firebase token unavailable: $e')), ); // Warm up the Nextcloud root listing in the background while the user is // still on the launch screen / other modules — the root endpoint is slow // on our instance, so kicking it off early means the Files page already // has data ready by the time the user navigates to it. No-op when a // cached payload is already present, so this does not undo the day-long // root cache TTL. if (SessionManager().hasNextcloud) { unawaited( ListFilesCache.prefetchRootListing().onError( (e, _) => log('Files root prefetch failed: $e'), ), ); } // Resolve the avatar cache directory ahead of the first avatar render so the // synchronous disk read hits and cold-start avatars appear without a blank // placeholder flash. AvatarDiskCache.instance.warmUp(); // Created eagerly so the endpoint is configured before anything below can // issue a request (the primary timetable bloc loads on creation). final settingsCubit = SettingsCubit(); _syncMarianumConnectEndpoint(settingsCubit.state); settingsCubit.stream.listen(_syncMarianumConnectEndpoint); log('running app...'); runApp( MultiBlocProvider( providers: [ BlocProvider.value(value: settingsCubit), BlocProvider( create: (_) => AccountBloc(initialStatus: _initialAccountStatus()), ), BlocProvider(create: (_) => BreakerBloc()), BlocProvider(create: (_) => CapabilitiesCubit()), BlocProvider( create: (_) => NextcloudCapabilitiesCubit(), ), BlocProvider(create: (_) => ChatListBloc()), BlocProvider( create: (ctx) => ChatBloc(chatListBloc: ctx.read()), ), BlocProvider(create: (_) => ChildSelectionCubit()), BlocProvider(create: (_) => ParentLettersBloc()), ], child: const PrimaryTimetableScope(child: Main()), ), ); } String? _syncedMcBaseUrl; /// Keeps the MC dio singleton aligned with the selected endpoint (live / /// beta / custom), mirrored into WidgetSync so the background isolate /// refreshes against the same endpoint. Settings emit on every toggle; only /// an actual URL change is applied. void _syncMarianumConnectEndpoint(Settings settings) { final url = settings.devToolsSettings.resolveMarianumConnectBaseUrl(); if (url == _syncedMcBaseUrl) return; _syncedMcBaseUrl = url; MarianumConnectEndpoint.update(url); unawaited(WidgetSync.setMarianumConnectBaseUrl(url)); } AccountStatus _initialAccountStatus() { final session = SessionManager(); if (session.isSignedIn) return AccountStatus.loggedIn; return session.isLoaded ? AccountStatus.loggedOut : AccountStatus.undefined; } class Main extends StatefulWidget { const Main({super.key}); static PersistentTabController bottomNavigator = PersistentTabController( initialIndex: 0, ); @override State
createState() => _MainState(); } class _MainState extends State
{ bool _showPostLoginSplash = false; bool _appMounted = true; @override void initState() { super.initState(); Jiffy.setLocale('de'); SessionManager().unauthorizedSignal.addListener(_onUnauthorized); SessionManager().waitForLoad().then((session) { if (!mounted) return; final accountBloc = context.read(); accountBloc.setStatus( session != null ? AccountStatus.loggedIn : AccountStatus.loggedOut, ); if (session != null) { _scheduleSessionValidation(accountBloc); // Cold start while already logged in: the account status doesn't // change, so the loggedIn listener below never fires. _onSessionActive(); } }); } /// Pulls the capability flags of the active account, then registers push /// right away instead of deferring it to the next app start. void _onSessionActive() { final settingsCubit = context.read(); final capabilitiesCubit = context.read(); unawaited( capabilitiesCubit.load().then((_) { if (!mounted) return; _syncPush(settingsCubit, capabilitiesCubit); _promptGuardianNotifications(); }), ); unawaited(context.read().load()); } /// Waits for the post-login splash so the dialog never covers it; the /// splash's completion calls this again. void _promptGuardianNotifications() { if (_showPostLoginSplash) return; final overlayContext = AppRoutes.overlayContext; if (overlayContext == null) return; unawaited(maybePromptGuardianLoginNotifications(overlayContext)); } /// Warms the chat list and files root in the background so the first screen /// render hits populated data. The timetable needs no warm-up: /// PrimaryTimetableScope creates a freshly loading bloc per account. void _prefetchBaseData(BuildContext context) { unawaited(context.read().refresh(silent: true)); unawaited(context.read().refresh(silent: true)); if (SessionManager().hasNextcloud) { unawaited(ListFilesCache.prefetchRootListing()); } } /// Registers/self-heals the push subscription whenever the backend advertises /// the capability — independent of the notification toggle, so a user with /// notifications off stays registered for silent sync pushes. Fire-and-forget. void _syncPush(SettingsCubit settings, CapabilitiesCubit capabilities) { final enabled = settings.val().notificationSettings.enabled; unawaited( PushRegistration.syncSubscription( capable: capabilities.canReceivePushNotifications, ).then((registered) { // The app-start heartbeat runs before this async registration // finishes, so it reports the pre-registration state. Re-emit once // the identifier is persisted so the new push status shows up this // session instead of only after the next launch. if (registered) { TelemetryHeartbeat.report(notificationsEnabled: enabled); } }), ); } @override void dispose() { SessionManager().unauthorizedSignal.removeListener(_onUnauthorized); super.dispose(); } void _onUnauthorized() { if (!mounted) return; final accountBloc = context.read(); if (accountBloc.state.status != AccountStatus.loggedIn) return; Debouncer.throttle( 'sessionUnauthorized', const Duration(seconds: 30), () => _scheduleSessionValidation(accountBloc), ); } /// Background credential check: a 401 means the password was rotated /// server-side, so the validator wipes the local session and flips the /// account bloc to `loggedOut` (sending the user to the login screen). void _scheduleSessionValidation(AccountBloc accountBloc) { unawaited( SessionValidator.probeStored( onInvalidated: () async { if (!mounted) return; accountBloc.setStatus(AccountStatus.loggedOut); }, ), ); } @override Widget build(BuildContext context) => Directionality( textDirection: TextDirection.ltr, child: BlocBuilder( builder: (context, settings) { final devToolsSettings = settings.devToolsSettings; // Mirror the notification toggle into group-scoped storage so the FCM // background isolate and the iOS NSE can suppress rendering when off. unawaited( const PushRegistrationStore().setNotificationsEnabled( settings.notificationSettings.enabled, ), ); return MaterialApp( showPerformanceOverlay: devToolsSettings.showPerformanceOverlay, checkerboardOffscreenLayers: devToolsSettings.checkerboardOffscreenLayers, checkerboardRasterCacheImages: devToolsSettings.checkerboardRasterCacheImages, debugShowCheckedModeBanner: false, navigatorKey: AppRoutes.rootNavigatorKey, // Used by ChatView.didPopNext to reclaim the global ChatBloc. // DownloadRouteObserver tracks full-page navigations so the downloads // chip only surfaces once the user leaves the screen they started on. navigatorObservers: [ AppRoutes.chatRouteObserver, DownloadRouteObserver(), ], localizationsDelegates: const [ ...GlobalMaterialLocalizations.delegates, GlobalWidgetsLocalizations.delegate, ], supportedLocales: const [Locale('de'), Locale('en')], locale: const Locale('de'), title: 'Marianum Fulda', themeMode: settings.appTheme, theme: LightAppTheme.theme, darkTheme: DarkAppTheme.theme, // Brand-colored backdrop behind every route. During the logout // home-swap and route pop animations the framework can briefly // expose the layer below the topmost Scaffold; without this // the dark Material default shows through and the user sees a // black flash. builder: (context, child) => ColoredBox( color: LightAppTheme.marianumRed, // Downloads tray mounted ABOVE the navigator so its chip floats over // every route (folder views, chat, viewer are full-page pushes that // would otherwise cover it). child: DownloadTrayHost(child: child ?? const SizedBox.shrink()), ), home: EmergencyNoticeGate( child: LoaderOverlay( child: Breaker( breaker: BreakerArea.global, child: BlocConsumer( listenWhen: (previous, current) => previous.status != current.status, listener: (context, accountState) { if (accountState.status == AccountStatus.loggedIn) { _onSessionActive(); _showPostLoginSplash = true; _appMounted = false; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) setState(() => _appMounted = true); }); _prefetchBaseData(context); } if (accountState.status != AccountStatus.loggedOut) return; // A pending share would otherwise survive logout and be // re-applied after re-login with file paths the OS may // already have evicted from the cache. ShareIntentListener.instance.clear(); // Routes pushed via AppRoutes (e.g. Settings) live on the // root navigator and survive the home swap below, so they // would still cover the Login screen after logout. Pop // them here so the user immediately sees Login. final navigator = Navigator.of(context); if (navigator.canPop()) { navigator.popUntil((route) => route.isFirst); } // Capture bloc references before the post-frame callback // — by the time it runs the dialog/Settings context is // gone but this listener context is still valid. final settingsCubit = context.read(); final breakerBloc = context.read(); final capabilitiesCubit = context.read(); final childSelectionCubit = context .read(); final chatListBloc = context.read(); final parentLettersBloc = context.read(); final chatBloc = context.read(); final nextcloudCapabilitiesCubit = context .read(); // Defer the actual wipe until after this frame so the // App tree (TimetableBloc/ChatListBloc watchers etc.) // is already torn down. Resetting blocs while App is // still in front caused a black-frame race. WidgetsBinding.instance.addPostFrameCallback((_) { unawaited( _wipeUserState( settingsCubit: settingsCubit, childSelectionCubit: childSelectionCubit, chatListBloc: chatListBloc, parentLettersBloc: parentLettersBloc, chatBloc: chatBloc, breakerBloc: breakerBloc, capabilitiesCubit: capabilitiesCubit, nextcloudCapabilitiesCubit: nextcloudCapabilitiesCubit, ), ); }); }, builder: (context, accountState) { switch (accountState.status) { case AccountStatus.loggedIn: return Stack( fit: StackFit.expand, children: [ if (_appMounted) const App(key: ValueKey('app-shell')), if (_showPostLoginSplash) PostLoginSplash( key: const ValueKey('post-login-splash'), onComplete: () { setState(() => _showPostLoginSplash = false); _promptGuardianNotifications(); }, ), ], ); case AccountStatus.loggedOut: return const Login(); case AccountStatus.undefined: return const AccountLoadingScreen(); } }, ), ), ), ), ); }, ), ); } Future _wipeUserState({ required SettingsCubit settingsCubit, required ChildSelectionCubit childSelectionCubit, required ChatListBloc chatListBloc, required ParentLettersBloc parentLettersBloc, required ChatBloc chatBloc, required BreakerBloc breakerBloc, required CapabilitiesCubit capabilitiesCubit, required NextcloudCapabilitiesCubit nextcloudCapabilitiesCubit, }) async { try { // Reset user-data blocs whose tree is no longer mounted after the // home swap. We do NOT touch SettingsCubit here — its outer BlocBuilder // wraps MaterialApp, so emit'ing a fresh state would tear down the // freshly-mounted Login tree and leave the user with a blank screen // (the MaterialApp.builder backdrop) until the next interaction. // The timetable bloc is not reset here: PrimaryTimetableScope replaces it // on the status change, and HydratedBloc.storage.clear() below drops the // cached plans of every subject. childSelectionCubit.reset(); capabilitiesCubit.reset(); nextcloudCapabilitiesCubit.reset(); await Future.wait([ chatListBloc.reset(), parentLettersBloc.reset(), chatBloc.reset(), breakerBloc.reset(), ]); final prefs = await SharedPreferences.getInstance(); await prefs.clear(); await HydratedBloc.storage.clear(); await const CacheView().clear(); // The chat background image lives outside HydratedStorage, so clear it too // (best-effort) to avoid orphaning the previous user's wallpaper. final backgroundImage = File(AppPaths.chatBackgroundImage); if (backgroundImage.existsSync()) backgroundImage.deleteSync(); // Stop the periodic widget refresh job so the background isolate doesn't // wake up every 30 minutes only to write `loggedIn=false`. Re-registers // on the next successful login. await WidgetBackgroundTask.cancelAll(); await WidgetSync.clear(); await WidgetSync.triggerUpdate(); } catch (e, s) { log('User state wipe failed: $e', stackTrace: s); } }