603 lines
24 KiB
Dart
603 lines
24 KiB
Dart
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<void> _startupStep(
|
|
String name,
|
|
Future<void> 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<void> main() async {
|
|
log('MarianumMobile started');
|
|
WidgetsFlutterBinding.ensureInitialized();
|
|
// Before any initialisation so startup failures reach the backend too.
|
|
_installErrorHandlers();
|
|
|
|
Future<void> 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<SettingsCubit>.value(value: settingsCubit),
|
|
BlocProvider<AccountBloc>(
|
|
create: (_) => AccountBloc(initialStatus: _initialAccountStatus()),
|
|
),
|
|
BlocProvider<BreakerBloc>(create: (_) => BreakerBloc()),
|
|
BlocProvider<CapabilitiesCubit>(create: (_) => CapabilitiesCubit()),
|
|
BlocProvider<NextcloudCapabilitiesCubit>(
|
|
create: (_) => NextcloudCapabilitiesCubit(),
|
|
),
|
|
BlocProvider<ChatListBloc>(create: (_) => ChatListBloc()),
|
|
BlocProvider<ChatBloc>(
|
|
create: (ctx) => ChatBloc(chatListBloc: ctx.read<ChatListBloc>()),
|
|
),
|
|
BlocProvider<ChildSelectionCubit>(create: (_) => ChildSelectionCubit()),
|
|
BlocProvider<ParentLettersBloc>(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<Main> createState() => _MainState();
|
|
}
|
|
|
|
class _MainState extends State<Main> {
|
|
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>();
|
|
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<SettingsCubit>();
|
|
final capabilitiesCubit = context.read<CapabilitiesCubit>();
|
|
unawaited(
|
|
capabilitiesCubit.load().then((_) {
|
|
if (!mounted) return;
|
|
_syncPush(settingsCubit, capabilitiesCubit);
|
|
_promptGuardianNotifications();
|
|
}),
|
|
);
|
|
unawaited(context.read<NextcloudCapabilitiesCubit>().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<ChatListBloc>().refresh(silent: true));
|
|
unawaited(context.read<ParentLettersBloc>().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<AccountBloc>();
|
|
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<SettingsCubit, Settings>(
|
|
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<AccountBloc, AccountState>(
|
|
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<SettingsCubit>();
|
|
final breakerBloc = context.read<BreakerBloc>();
|
|
final capabilitiesCubit = context.read<CapabilitiesCubit>();
|
|
final childSelectionCubit = context
|
|
.read<ChildSelectionCubit>();
|
|
final chatListBloc = context.read<ChatListBloc>();
|
|
final parentLettersBloc = context.read<ParentLettersBloc>();
|
|
final chatBloc = context.read<ChatBloc>();
|
|
final nextcloudCapabilitiesCubit = context
|
|
.read<NextcloudCapabilitiesCubit>();
|
|
// 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<void> _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.
|
|
await Future.wait([
|
|
childSelectionCubit.reset(),
|
|
chatListBloc.reset(),
|
|
parentLettersBloc.reset(),
|
|
chatBloc.reset(),
|
|
breakerBloc.reset(),
|
|
capabilitiesCubit.reset(),
|
|
nextcloudCapabilitiesCubit.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);
|
|
}
|
|
}
|