From 498d195138a0c0780ad2b651f87979bcfd62a564 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Thu, 24 Sep 2026 21:43:21 +0200 Subject: [PATCH 01/18] fixed stale state after logout and replayed or lost share intents --- android/app/src/main/AndroidManifest.xml | 2 +- .../marianum/mobile/client/MainActivity.kt | 47 +++- ios/Runner/SceneDelegate.swift | 27 ++- ios/Share Extension/ShareViewController.swift | 56 ++++- lib/api/errors/stale_session_exception.dart | 11 + .../talk/share_files_to_chat.dart | 4 + lib/api/marianumcloud/webdav/webdav_api.dart | 5 +- .../auth/auth_interceptor.dart | 18 +- .../auth/session_validator.dart | 4 + .../custom_events_migration.dart | 5 + lib/api/request_cache.dart | 13 +- lib/app.dart | 5 +- lib/main.dart | 61 +---- lib/model/account_data.dart | 56 +++++ lib/model/session_wipe.dart | 152 ++++++++++++ lib/push/push_actions.dart | 4 + lib/push/push_message_handler.dart | 3 + lib/push/push_registration.dart | 8 + lib/share_intent/pending_share.dart | 10 +- lib/share_intent/share_intent_listener.dart | 218 ++++++++++++++---- .../loadable_hydrated_bloc.dart | 64 +++-- .../capabilities/bloc/capabilities_cubit.dart | 5 + .../app/modules/chat/bloc/chat_bloc.dart | 9 + .../chat_list/bloc/chat_list_bloc.dart | 7 + .../bloc/nextcloud_capabilities_cubit.dart | 5 + .../timetable/bloc/timetable_bloc.dart | 6 +- lib/view/login/login_controller.dart | 5 + lib/view/pages/files/files_upload_dialog.dart | 24 +- .../pages/share_intent/share_chat_picker.dart | 54 +++-- .../share_intent/share_folder_picker.dart | 25 +- lib/widget_data/widget_publisher.dart | 5 + test/share_intent/pending_share_test.dart | 13 ++ 32 files changed, 743 insertions(+), 188 deletions(-) create mode 100644 lib/api/errors/stale_session_exception.dart create mode 100644 lib/model/session_wipe.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 3800d0c..123c275 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -9,7 +9,7 @@ result.notImplemented() } } + MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + shareChannel + ).setMethodCallHandler { call, result -> + when (call.method) { + "listenerReady" -> { + shareListenerReady = true + bufferedShare?.let { flutterEngine.activityControlSurface.onNewIntent(it) } + bufferedShare = null + result.success(null) + } + else -> result.notImplemented() + } + } consumeIntentData(intent) } override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) + if (!shareListenerReady && intent.action in shareActions) { + bufferedShare = intent + } consumeIntentData(intent) } + private fun isWidgetTap(intent: Intent?): Boolean = + intent?.getBooleanExtra("widget_open_timetable", false) == true + private fun consumeIntentData(intent: Intent?) { - if (intent?.getBooleanExtra("widget_open_timetable", false) == true) { - pendingTimetableTap = true - } + if (isWidgetTap(intent)) pendingTimetableTap = true } } diff --git a/ios/Runner/SceneDelegate.swift b/ios/Runner/SceneDelegate.swift index eb61b78..6c8a831 100644 --- a/ios/Runner/SceneDelegate.swift +++ b/ios/Runner/SceneDelegate.swift @@ -13,21 +13,44 @@ class SceneDelegate: FlutterSceneDelegate { options connectionOptions: UIScene.ConnectionOptions ) { super.scene(scene, willConnectTo: session, options: connectionOptions) - for context in connectionOptions.urlContexts { + var handledShare = false + for context in connectionOptions.urlContexts where isShareUrl(context.url) { _ = ReceiveSharingIntentPlugin.instance.application( UIApplication.shared, didFinishLaunchingWithOptions: [UIApplication.LaunchOptionsKey.url: context.url] ) + handledShare = true } + if handledShare { clearSharedPayload() } } override func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { - for context in URLContexts { + let shares = URLContexts.filter { isShareUrl($0.url) } + for context in shares { _ = ReceiveSharingIntentPlugin.instance.application( UIApplication.shared, open: context.url, options: [:] ) } + if !shares.isEmpty { clearSharedPayload() } + let others = URLContexts.subtracting(shares) + if !others.isEmpty { super.scene(scene, openURLContexts: others) } + } + + private func isShareUrl(_ url: URL) -> Bool { + ReceiveSharingIntentPlugin.instance.hasMatchingSchemePrefix(url: url) + } + + // The plugin keeps the payload in memory once read but never removes it + // from the App Group defaults. The Share Extension opens the app twice per + // share and a scene reconnect replays the URL, so every later delivery + // would publish the finished share again. + private func clearSharedPayload() { + let groupId = Bundle.main.object(forInfoDictionaryKey: "AppGroupId") as? String + let defaults = UserDefaults( + suiteName: groupId ?? "group.\(Bundle.main.bundleIdentifier ?? "")") + defaults?.removeObject(forKey: "ShareKey") + defaults?.removeObject(forKey: "ShareMessageKey") } } diff --git a/ios/Share Extension/ShareViewController.swift b/ios/Share Extension/ShareViewController.swift index 881b42f..a54b551 100644 --- a/ios/Share Extension/ShareViewController.swift +++ b/ios/Share Extension/ShareViewController.swift @@ -152,17 +152,54 @@ final class ShareViewController: UIViewController { } } - private func copyIntoAppGroup(src: URL) -> URL? { + // loadItem-Callbacks laufen parallel; Ordneranlage und Namenswahl seriell. + private let fileQueue = DispatchQueue(label: "share.files") + + // Ein Ordner pro Share: Gleichnamige Dateien (z. B. bearbeitete Fotos, alle + // "FullSizeRender.jpg") überschrieben sich sonst gegenseitig, auch über + // zwei Shares hinweg. Die App löscht den Ordner, sobald der Share erledigt ist. + private lazy var shareDir: URL? = { guard let container = FileManager.default .containerURL(forSecurityApplicationGroupIdentifier: appGroupId) else { return nil } - let name = src.lastPathComponent.isEmpty ? UUID().uuidString : src.lastPathComponent - let dst = container.appendingPathComponent(name) + let root = container.appendingPathComponent("share_intent", isDirectory: true) + sweepStaleShares(in: root) + let dir = root.appendingPathComponent(UUID().uuidString, isDirectory: true) + do { + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } catch { + return nil + } + }() + + // Shares, deren App-Flow nie abgeschlossen wurde (Prozess beendet o. ä.), + // räumt sonst niemand weg — der App-Group-Container wird nie geleert. + private func sweepStaleShares(in root: URL) { + let fm = FileManager.default + guard let entries = try? fm.contentsOfDirectory( + at: root, includingPropertiesForKeys: [.creationDateKey]) else { return } + let cutoff = Date().addingTimeInterval(-24 * 60 * 60) + for entry in entries { + let created = (try? entry.resourceValues(forKeys: [.creationDateKey]))?.creationDate + if let created, created < cutoff { try? fm.removeItem(at: entry) } + } + } + + private func copyIntoAppGroup(src: URL) -> URL? { + fileQueue.sync { copyIntoShareDir(src: src) } + } + + private func copyIntoShareDir(src: URL) -> URL? { + guard let dir = shareDir else { return nil } + let name = src.lastPathComponent.isEmpty ? UUID().uuidString : src.lastPathComponent + var dst = dir.appendingPathComponent(name) + // Mehrfachauswahl mit gleichem Namen innerhalb eines Shares. + if FileManager.default.fileExists(atPath: dst.path) { + dst = dir.appendingPathComponent("\(UUID().uuidString.prefix(8))-\(name)") + } do { - if FileManager.default.fileExists(atPath: dst.path) { - try FileManager.default.removeItem(at: dst) - } try FileManager.default.copyItem(at: src, to: dst) return dst } catch { @@ -171,10 +208,9 @@ final class ShareViewController: UIViewController { } private func writePng(image: UIImage) -> URL? { - guard let container = FileManager.default - .containerURL(forSecurityApplicationGroupIdentifier: appGroupId), - let data = image.pngData() else { return nil } - let dst = container.appendingPathComponent("\(UUID().uuidString).png") + guard let data = image.pngData(), + let dir = fileQueue.sync(execute: { shareDir }) else { return nil } + let dst = dir.appendingPathComponent("\(UUID().uuidString).png") return (try? data.write(to: dst)) != nil ? dst : nil } diff --git a/lib/api/errors/stale_session_exception.dart b/lib/api/errors/stale_session_exception.dart new file mode 100644 index 0000000..52936e1 --- /dev/null +++ b/lib/api/errors/stale_session_exception.dart @@ -0,0 +1,11 @@ +import 'app_exception.dart'; + +/// A request finished after the account it was started for signed out. Its +/// result must not reach the state or cache of whoever is signed in now. +class StaleSessionException extends AppException { + const StaleSessionException() + : super( + userMessage: 'Die Anmeldung hat sich geändert. Bitte lade neu.', + technicalDetails: 'result of a previous session discarded', + ); +} diff --git a/lib/api/marianumcloud/talk/share_files_to_chat.dart b/lib/api/marianumcloud/talk/share_files_to_chat.dart index 9be0faa..5a7facb 100644 --- a/lib/api/marianumcloud/talk/share_files_to_chat.dart +++ b/lib/api/marianumcloud/talk/share_files_to_chat.dart @@ -17,6 +17,10 @@ Future? _shareFolderReady; Future ensureTalkShareFolder() => _shareFolderReady ??= _createTalkShareFolder(); +/// The folder is per user; after a sign-out the next account has to check +/// again. +void resetTalkShareFolderCache() => _shareFolderReady = null; + Future _createTalkShareFolder() async { try { final webdav = await WebdavApi.webdav; diff --git a/lib/api/marianumcloud/webdav/webdav_api.dart b/lib/api/marianumcloud/webdav/webdav_api.dart index 942b560..5395706 100644 --- a/lib/api/marianumcloud/webdav/webdav_api.dart +++ b/lib/api/marianumcloud/webdav/webdav_api.dart @@ -18,7 +18,10 @@ abstract class WebdavApi { /// changes (app password minted/renewed, account switch) so it never keeps /// authenticating with stale credentials. static Future get webdav { - final secret = AccountData().getNextcloudSecret(); + // Keyed by user too: two accounts may share a password (no app password + // minted), and the client would keep the previous login name. + final secret = + '${AccountData().getUsername()}:${AccountData().getNextcloudSecret()}'; if (_webdav == null || _webdavSecret != secret) { _webdavSecret = secret; _webdav = establishWebdavConnection(); diff --git a/lib/api/marianumconnect/auth/auth_interceptor.dart b/lib/api/marianumconnect/auth/auth_interceptor.dart index b78ac97..7dc4e2a 100644 --- a/lib/api/marianumconnect/auth/auth_interceptor.dart +++ b/lib/api/marianumconnect/auth/auth_interceptor.dart @@ -88,17 +88,29 @@ class MarianumConnectAuthInterceptor extends Interceptor { Future _performReLogin() async { if (!AccountData().isPopulated()) return false; + final username = AccountData().getUsername(); + // A background engine (widget task) keeps the account it loaded. When + // the app signed that account out, its revoked token answers 401 — a + // re-login would mint a fresh token for it into the shared keystore. + if (await AccountData().readStoredUsername() != username) return false; try { await _loginClient.run( - username: AccountData().getUsername(), + username: username, password: AccountData().getPassword(), tokenName: await DeviceTokenName.resolve(), ); - return true; } catch (_) { - await _tokenStorage.clear(); + if (await AccountData().readStoredUsername() == username) { + await _tokenStorage.clear(); + } return false; } + final stored = await AccountData().readStoredUsername(); + if (stored == username) return true; + // Signed out during the login: drop the orphaned token, unless another + // account already stored its own. + if (stored == null) await _tokenStorage.clear(); + return false; } Future> _retryWithFreshToken( diff --git a/lib/api/marianumconnect/auth/session_validator.dart b/lib/api/marianumconnect/auth/session_validator.dart index 4280b4b..355b0bb 100644 --- a/lib/api/marianumconnect/auth/session_validator.dart +++ b/lib/api/marianumconnect/auth/session_validator.dart @@ -19,10 +19,14 @@ class SessionValidator { if (AccountData().isDemo) return; final username = AccountData().getUsername(); final password = AccountData().getPassword(); + final epoch = AccountData().sessionEpoch; try { await AuthVerify().run(username: username, password: password); } on AuthException catch (e) { if (e.statusCode != 401) return; + // The probed account already signed out; the 401 must not sign out + // whoever logged in meanwhile. + if (!AccountData().isCurrentSession(epoch)) return; log('MC: stored credentials rejected — forcing re-login'); await AuthLogout().run(); await const MarianumConnectTokenStorage().clear(); diff --git a/lib/api/marianumconnect/queries/timetable_custom_events/custom_events_migration.dart b/lib/api/marianumconnect/queries/timetable_custom_events/custom_events_migration.dart index ab63377..613e598 100644 --- a/lib/api/marianumconnect/queries/timetable_custom_events/custom_events_migration.dart +++ b/lib/api/marianumconnect/queries/timetable_custom_events/custom_events_migration.dart @@ -31,18 +31,23 @@ class CustomEventsMigration { if (DemoMode.active) return; if (await _isDone()) return; + final epoch = AccountData().sessionEpoch; try { final response = await GetCustomTimetableEvent( GetCustomTimetableEventParams(AccountData().getUserSecret()), ).run(); for (final event in response.events) { + // The POST authenticates with whoever is signed in now; after a + // sign-out the previous account's events would land in the next one. + if (!AccountData().isCurrentSession(epoch)) return; await TimetableCustomEventsAdd().run(event); await RemoveCustomTimetableEvent( RemoveCustomTimetableEventParams(event.id), ).run(); } + if (!AccountData().isCurrentSession(epoch)) return; await _markDone(); log('Custom events migration: moved ${response.events.length} event(s) to Marianum-Connect.'); } catch (e) { diff --git a/lib/api/request_cache.dart b/lib/api/request_cache.dart index 86d705b..bdf86ea 100644 --- a/lib/api/request_cache.dart +++ b/lib/api/request_cache.dart @@ -3,8 +3,10 @@ import 'dart:convert'; import 'package:localstore/localstore.dart'; +import '../model/account_data.dart'; import 'api_response.dart'; import 'errors/parse_exception.dart'; +import 'errors/stale_session_exception.dart'; abstract class RequestCache { static const int cacheNothing = 0; @@ -48,6 +50,7 @@ abstract class RequestCache { static void ignore(Exception e) {} Future start(String document) async { + final epoch = AccountData().sessionEpoch; try { final tableData = await Localstore.instance .collection(collection) @@ -67,6 +70,12 @@ abstract class RequestCache { try { final newValue = await onLoad(); + // The collection is shared, so a late response of a signed-out + // account would otherwise be cached for the next one. + if (!AccountData().isCurrentSession(epoch)) { + onError(const StaleSessionException()); + return; + } onUpdate?.call(newValue); onNetworkData?.call(newValue); unawaited( @@ -141,8 +150,10 @@ Future resolveFromCache( onError?.call(e); }); await cache.ready; - if (latest != null) return latest as T; final err = capturedError; + // `latest` may still hold the cache hit read before the sign-out. + if (err is StaleSessionException) throw err; + if (latest != null) return latest as T; if (err != null) throw err; throw ParseException( technicalDetails: operationName != null diff --git a/lib/app.dart b/lib/app.dart index c78f748..c346f69 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -145,13 +145,14 @@ class _AppState extends State with WidgetsBindingObserver { void _handlePendingShare() { if (!mounted) return; final share = ShareIntentListener.pending.value; - if (share == null) return; + if (share == null || ShareIntentListener.instance.isShown(share)) return; // A second share would otherwise leave the previous share-flow page // on top with stale (already-cleared) file paths. final navigator = Navigator.of(context); if (navigator.canPop()) { navigator.popUntil((route) => route.isFirst || route is PopupRoute); } + ShareIntentListener.instance.markShown(share); AppRoutes.openShareTarget(context, share); } @@ -200,7 +201,6 @@ class _AppState extends State with WidgetsBindingObserver { ); } unawaited(_handlePendingWidgetNavigation()); - ShareIntentListener.instance.attach(); ShareIntentListener.pending.addListener(_handlePendingShare); _handlePendingShare(); _syncChatListPolling(); @@ -261,7 +261,6 @@ class _AppState extends State with WidgetsBindingObserver { PushTapRouter.pendingChatToken.removeListener(_onPushTapPending); PushTapRouter.pendingNewsletterId.removeListener(_onNewsletterTapPending); ShareIntentListener.pending.removeListener(_handlePendingShare); - ShareIntentListener.instance.detach(); Main.bottomNavigator.removeListener(_onTabControllerChanged); WidgetsBinding.instance.removeObserver(this); super.dispose(); diff --git a/lib/main.dart b/lib/main.dart index e06a754..2eafc98 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,7 +15,6 @@ 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'; @@ -27,6 +26,7 @@ import 'app.dart'; import 'background/widget_background_task.dart'; import 'firebase_options.dart'; import 'model/account_data.dart'; +import 'model/session_wipe.dart'; import 'notification/notification_service.dart'; import 'push/push_message_handler.dart'; import 'push/push_registration.dart'; @@ -54,7 +54,6 @@ 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'; @@ -117,6 +116,7 @@ void _installErrorHandlers() { Future main() async { log('MarianumMobile started'); WidgetsFlutterBinding.ensureInitialized(); + AccountData().markUiEngine(); // Before any initialisation so startup failures reach the backend too. _installErrorHandlers(); @@ -272,11 +272,13 @@ class Main extends StatefulWidget { class _MainState extends State
{ bool _showPostLoginSplash = false; bool _appMounted = true; + late AccountStatus _lastStatus; @override void initState() { super.initState(); Jiffy.setLocale('de'); + _lastStatus = context.read().state.status; AccountData().waitForPopulation().then((value) { if (!mounted) return; @@ -409,6 +411,8 @@ class _MainState extends State
{ listenWhen: (previous, current) => previous.status != current.status, listener: (context, accountState) { + final wasLoggedIn = _lastStatus == AccountStatus.loggedIn; + _lastStatus = accountState.status; // Fresh login (loggedOut -> loggedIn): pull capability flags // for the newly authenticated user, then register push right // away instead of deferring it to the next app start. @@ -434,9 +438,10 @@ class _MainState extends State
{ } 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(); + // re-applied to the next account. A cold-start share that + // is merely waiting for the stored session to resolve as + // signed out stays, to be sent after login. + if (wasLoggedIn) SessionWipe.clearImmediate(); // 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 @@ -448,7 +453,6 @@ class _MainState extends State
{ // 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 timetableBloc = context.read(); final chatListBloc = context.read(); final chatBloc = context.read(); @@ -462,8 +466,7 @@ class _MainState extends State
{ // still in front caused a black-frame race. WidgetsBinding.instance.addPostFrameCallback((_) { unawaited( - _wipeUserState( - settingsCubit: settingsCubit, + SessionWipe.run( timetableBloc: timetableBloc, chatListBloc: chatListBloc, chatBloc: chatBloc, @@ -507,45 +510,3 @@ class _MainState extends State
{ ), ); } - -Future _wipeUserState({ - required SettingsCubit settingsCubit, - required TimetableBloc timetableBloc, - required ChatListBloc chatListBloc, - 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. - await Future.wait([ - timetableBloc.reset(), - chatListBloc.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); - } -} diff --git a/lib/model/account_data.dart b/lib/model/account_data.dart index 4f96459..518d5fb 100644 --- a/lib/model/account_data.dart +++ b/lib/model/account_data.dart @@ -55,6 +55,15 @@ class AccountData { unawaited(_loadWithRetry()); } + int _sessionEpoch = 0; + + /// Bumped on every sign-out. Async work captures it when it starts and + /// drops its result when it changed meanwhile, so a request of the previous + /// account cannot land in the next account's state or cache. + int get sessionEpoch => _sessionEpoch; + + bool isCurrentSession(int epoch) => epoch == _sessionEpoch; + String? _username; String? _password; String? _appPassword; @@ -109,6 +118,7 @@ class AccountData { } Future removeData() async { + _sessionEpoch++; _populated = Completer(); _username = null; _password = null; @@ -263,6 +273,52 @@ class AccountData { } } + bool _isUiEngine = false; + + /// Called from `main()`; background entry points never run it. + void markUiEngine() => _isUiEngine = true; + + /// Username currently in the keystore. Other engines (widget task, push + /// isolates) sign out or in without this instance noticing. + Future readStoredUsername() => + _secureStorage.read(key: _usernameField); + + /// Re-reads the session for long-lived background engines: they load once + /// and would otherwise keep acting with an account that signed out in the + /// app meanwhile. Keeps the known state when the keystore is unreadable. + Future reloadFromStorage() async { + // The UI engine performs sign-in and sign-out itself, so its state is + // current; re-reading mid sign-out could resurrect the removed account. + if (_isUiEngine) return; + try { + final username = await _secureStorage.read(key: _usernameField); + final password = await _secureStorage.read(key: _passwordField); + final isDemo = (await _secureStorage.read(key: _demoField)) == 'true'; + final usesLoginFlow = + (await _secureStorage.read(key: _loginFlowField)) == 'true'; + String? appPassword; + String? appPasswordTalk; + try { + appPassword = await pushSecureStorage.read(key: _appPasswordField); + appPasswordTalk = await pushSecureStorage.read( + key: _appPasswordTalkField, + ); + } on Object { + // Group keystore unavailable: fall back to the real password. + } + if (username != _username) _sessionEpoch++; + _username = username; + _password = password; + _isDemo = isDemo; + _usesLoginFlow = usesLoginFlow; + _appPassword = appPassword; + _appPasswordTalk = appPasswordTalk; + if (!_populated.isCompleted) _populated.complete(); + } on Object catch (e) { + log('AccountData reload failed, keeping loaded state: $e'); + } + } + Future waitForPopulation() async { await _populated.future; return isPopulated(); diff --git a/lib/model/session_wipe.dart b/lib/model/session_wipe.dart new file mode 100644 index 0000000..b54eff8 --- /dev/null +++ b/lib/model/session_wipe.dart @@ -0,0 +1,152 @@ +import 'dart:async'; +import 'dart:developer'; +import 'dart:io'; + +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter_app_badge/flutter_app_badge.dart'; +import 'package:hydrated_bloc/hydrated_bloc.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../api/marianumcloud/talk/share_files_to_chat.dart'; +import '../background/widget_background_task.dart'; +import '../notification/notification_service.dart'; +import '../push/chat_thread_store.dart'; +import '../push/nid_store.dart'; +import '../push/push_keypair.dart'; +import '../push/push_tap_router.dart'; +import '../routing/app_routes.dart'; +import '../share_intent/share_intent_listener.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/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart'; +import '../state/app/modules/timetable/bloc/timetable_bloc.dart'; +import '../utils/app_paths.dart'; +import '../utils/downloads/download_manager.dart'; +import '../utils/file_clipboard.dart'; +import '../widget/debug/cache_view.dart'; +import '../widget_data/widget_sync.dart'; + +/// Removes everything the signed-out account left on the device. Every step +/// is isolated: one failing step used to skip all later ones and leave the +/// previous account's data behind. +abstract final class SessionWipe { + static Future? _running; + + /// Completes once a running wipe finished. A login awaits it, otherwise the + /// wipe could clear what the next account just stored (widget job, caches). + static Future get done => _running ?? Future.value(); + + /// State that must be gone before the next frame (a login screen can be + /// reached right away): pending navigation and in-memory singletons. + static void clearImmediate() { + _step('share intents', ShareIntentListener.instance.clearAll); + _step('share folder cache', resetTalkShareFolderCache); + _step('pending navigation', () { + AppRoutes.pendingChatToken.value = null; + PushTapRouter.pendingChatToken.value = null; + PushTapRouter.pendingNewsletterId.value = null; + }); + _step('file clipboard', FileClipboard.instance.clear); + _step('downloads', DownloadManager.instance.clearAll); + } + + static Future run({ + required TimetableBloc timetableBloc, + required ChatListBloc chatListBloc, + required ChatBloc chatBloc, + required BreakerBloc breakerBloc, + required CapabilitiesCubit capabilitiesCubit, + required NextcloudCapabilitiesCubit nextcloudCapabilitiesCubit, + }) { + final wipe = _run( + timetableBloc: timetableBloc, + chatListBloc: chatListBloc, + chatBloc: chatBloc, + breakerBloc: breakerBloc, + capabilitiesCubit: capabilitiesCubit, + nextcloudCapabilitiesCubit: nextcloudCapabilitiesCubit, + ); + _running = wipe; + return wipe.whenComplete(() { + if (identical(_running, wipe)) _running = null; + }); + } + + static Future _run({ + required TimetableBloc timetableBloc, + required ChatListBloc chatListBloc, + required ChatBloc chatBloc, + required BreakerBloc breakerBloc, + required CapabilitiesCubit capabilitiesCubit, + required NextcloudCapabilitiesCubit nextcloudCapabilitiesCubit, + }) async { + // SettingsCubit is deliberately left alone: its BlocBuilder wraps + // MaterialApp, and emitting a fresh state here tore down the freshly + // mounted Login tree (blank screen until the next interaction). + await Future.wait([ + _stepAsync('timetable', timetableBloc.reset), + _stepAsync('chat list', chatListBloc.reset), + _stepAsync('chat', chatBloc.reset), + _stepAsync('breakers', breakerBloc.reset), + _stepAsync('capabilities', capabilitiesCubit.reset), + _stepAsync('nc capabilities', nextcloudCapabilitiesCubit.reset), + ]); + await _stepAsync('shared preferences', () async { + await (await SharedPreferences.getInstance()).clear(); + }); + await _stepAsync('hydrated storage', HydratedBloc.storage.clear); + await _stepAsync('request cache', const CacheView().clear); + await _stepAsync('chat background', () async { + final image = File(AppPaths.chatBackgroundImage); + if (image.existsSync()) await image.delete(); + }); + await _stepAsync('download files', () async { + final dir = Directory('${(await getTemporaryDirectory()).path}/downloads'); + if (dir.existsSync()) await dir.delete(recursive: true); + }); + + // Push: the tray and its bookkeeping belong to the previous account — a + // tap or inline reply there would otherwise act as the next one. A new + // keypair and FCM token make pushes still addressed to the previous + // registration undecryptable and undeliverable (an offline sign-out + // could not unregister it). + await _stepAsync( + 'notification tray', + NotificationService().flutterLocalNotificationsPlugin.cancelAll, + ); + await _stepAsync('app badge', () => FlutterAppBadge.count(0)); + await _stepAsync('push nid store', NidStore().clear); + await _stepAsync('push thread store', ChatThreadStore().clearAll); + await _stepAsync('push keypair', const PushKeypair().clear); + await _stepAsync('fcm token', FirebaseMessaging.instance.deleteToken); + + // 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 _stepAsync('widget task', WidgetBackgroundTask.cancelAll); + await _stepAsync('widget data', WidgetSync.clear); + await _stepAsync('widget update', WidgetSync.triggerUpdate); + } + + static void _step(String name, void Function() step) { + try { + step(); + } catch (e, s) { + log('Session wipe step "$name" failed: $e', stackTrace: s); + } + } + + static Future _stepAsync( + String name, + Future Function() step, + ) async { + try { + await step(); + } catch (e, s) { + log('Session wipe step "$name" failed: $e', stackTrace: s); + } + } +} diff --git a/lib/push/push_actions.dart b/lib/push/push_actions.dart index 3548143..a2d1f42 100644 --- a/lib/push/push_actions.dart +++ b/lib/push/push_actions.dart @@ -60,6 +60,10 @@ class PushActions { // auth header, the Talk POST never happens and the RemoteInput spinner // runs forever. DartPluginRegistrant.ensureInitialized(); + // The action engine lives as long as the process: without a reload a + // reply would be sent with the account that was signed in when the + // engine first started. + await AccountData().reloadFromStorage(); _plog( 'action=${response.actionId} payload=${response.payload} ' diff --git a/lib/push/push_message_handler.dart b/lib/push/push_message_handler.dart index 1d64116..277c2c0 100644 --- a/lib/push/push_message_handler.dart +++ b/lib/push/push_message_handler.dart @@ -4,6 +4,7 @@ import 'package:crypton/crypton.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import '../background/widget_background_task.dart'; +import '../model/account_data.dart'; import '../notification/notification_service.dart'; import 'chat_thread_store.dart'; import 'nid_store.dart'; @@ -54,6 +55,8 @@ PushKind classifyPush(Map data) { /// "must be annotated"), so a plain function is the reliable form. @pragma('vm:entry-point') Future pushOnBackgroundMessage(RemoteMessage message) async { + // This engine outlives sign-outs and logins in the app. + await AccountData().reloadFromStorage(); await NotificationService().initializeNotifications(); await PushRenderer.ensureChannels(); await PushMessageHandler().handle(message); diff --git a/lib/push/push_registration.dart b/lib/push/push_registration.dart index 1f0d1b7..52dc540 100644 --- a/lib/push/push_registration.dart +++ b/lib/push/push_registration.dart @@ -107,6 +107,7 @@ class PushRegistration { /// fire-and-forget (and simply ignore the result). Future register() async { if (DemoMode.active) return false; + final epoch = AccountData().sessionEpoch; final String? fcmToken; try { fcmToken = await FirebaseMessaging.instance.getToken(); @@ -159,6 +160,7 @@ class PushRegistration { fcmToken: fcmToken, pems: pems, appVersion: appVersion, + epoch: epoch, ); allOk = allOk && ok; } @@ -170,6 +172,7 @@ class PushRegistration { required String fcmToken, required PushKeypairPems pems, required String? appVersion, + required int epoch, }) async { try { final proxyServer = currentProxyServer; @@ -185,6 +188,11 @@ class PushRegistration { userAgent: isTalk ? _talkUserAgent : null, ); + // Signed out while registering: persisting or announcing this + // registration would keep delivering the previous account's pushes, + // and logoutCleanup already ran so nothing would unregister it. + if (!AccountData().isCurrentSession(epoch)) return false; + await _store.save( type: type, deviceIdentifier: registration.deviceIdentifier, diff --git a/lib/share_intent/pending_share.dart b/lib/share_intent/pending_share.dart index 9f8590e..6c875e2 100644 --- a/lib/share_intent/pending_share.dart +++ b/lib/share_intent/pending_share.dart @@ -5,11 +5,17 @@ class PendingShare { final String? text; final DateTime receivedAt; + /// Paths as delivered by the platform, before the listener moved them into + /// a per-share folder. Duplicate detection compares these, since the + /// relocated [filePaths] differ for every delivery. + final List sourcePaths; + const PendingShare({ required this.filePaths, required this.text, required this.receivedAt, - }); + List? sourcePaths, + }) : sourcePaths = sourcePaths ?? filePaths; bool get hasFiles => filePaths.isNotEmpty; bool get hasText => text != null && text!.isNotEmpty; @@ -20,5 +26,5 @@ class PendingShare { /// the same share can arrive twice on the media stream — receivedAt is /// deliberately ignored here so such duplicates compare equal. bool contentEquals(PendingShare other) => - text == other.text && listEquals(filePaths, other.filePaths); + text == other.text && listEquals(sourcePaths, other.sourcePaths); } diff --git a/lib/share_intent/share_intent_listener.dart b/lib/share_intent/share_intent_listener.dart index 27b429d..f1f355b 100644 --- a/lib/share_intent/share_intent_listener.dart +++ b/lib/share_intent/share_intent_listener.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:receive_sharing_intent/receive_sharing_intent.dart'; import 'pending_share.dart'; @@ -14,56 +16,63 @@ class ShareIntentListener { static final ValueNotifier pending = ValueNotifier(null); - StreamSubscription>? _streamSub; - bool _initialized = false; + static const _androidChannel = MethodChannel('eu.mhsl.marianum.share'); - /// Reads the cold-start payload exactly once. Call from `main()` before - /// `runApp` so the share is queued before the UI mounts. + /// Per-share folders live below this directory name — on Android in the + /// cache dir (created here), on iOS in the App Group container (created by + /// the Share Extension). + static const _shareDirName = 'share_intent'; + + bool _initialized = false; + String? _cacheDir; + + int _flowDepth = 0; + PendingShare? _queued; + PendingShare? _shown; + + /// Subscribes to warm shares and reads the cold-start payload. Call from + /// `main()` before `runApp`. The subscription stays for the process + /// lifetime: the plugin drops shares that arrive while nobody listens + /// (login screen, app remount), and [pending] buffers until the UI is ready. Future initialize() async { if (_initialized) return; _initialized = true; try { - final initial = await ReceiveSharingIntent.instance.getInitialMedia(); - final share = _toPendingShare(initial); - if (share != null) _publish(share); + if (Platform.isAndroid) { + _cacheDir = (await getTemporaryDirectory()).path; + _sweepStaleAndroidShares(); + } + ReceiveSharingIntent.instance.getMediaStream().listen( + _onItems, + onError: (Object e) => debugPrint('ShareIntentListener stream: $e'), + ); + _onItems(await ReceiveSharingIntent.instance.getInitialMedia()); await ReceiveSharingIntent.instance.reset(); + if (Platform.isAndroid) { + await _androidChannel.invokeMethod('listenerReady'); + } } catch (e) { debugPrint('ShareIntentListener.initialize failed: $e'); } } - /// Subscribes to warm-share stream events. Safe to call multiple times. - void attach() { - _streamSub ??= ReceiveSharingIntent.instance.getMediaStream().listen( - (items) { - final share = _toPendingShare(items); - if (share != null) _publish(share); - }, - onError: (Object e) => - debugPrint('ShareIntentListener stream error: $e'), - ); + /// Whether the share flow for [share] is already on screen. Re-running the + /// navigation for it would pop its own page and thereby clear it. + bool isShown(PendingShare share) => identical(share, _shown); + + void markShown(PendingShare share) => _shown = share; + + /// Marks a share flow step that must not be interrupted (upload, share API + /// call). Shares arriving meanwhile are queued instead of replacing the + /// current one, whose files are still in use. Pair with [endFlow]. + void beginFlow() => _flowDepth++; + + void endFlow() { + if (_flowDepth > 0) _flowDepth--; + if (_flowDepth == 0) _drainQueue(); } - /// The iOS Share Extension fires two `open(url)` requests per share, so the - /// same payload can arrive twice in quick succession. Publishing the - /// duplicate would re-trigger the share-flow navigation, pop the already - /// open ShareTargetPage and thereby delete the temp files of the share that - /// is still in flight — swallow it instead. - void _publish(PendingShare share) { - final current = pending.value; - if (current != null && current.contentEquals(share)) return; - pending.value = share; - } - - /// Cancels the warm-share subscription. The singleton survives, so a - /// subsequent [attach] re-subscribes. - void detach() { - _streamSub?.cancel(); - _streamSub = null; - } - - /// Discards the current share and removes any temp files the plugin copied - /// into the app cache. Idempotent. + /// Discards the current share and deletes its temp files. Idempotent. /// /// Pass [ifCurrent] from UI that owns a specific share (e.g. the /// ShareTargetPage pop handler): the call then only acts while that share @@ -73,18 +82,58 @@ class ShareIntentListener { void clear({PendingShare? ifCurrent}) { final current = pending.value; if (ifCurrent != null && !identical(current, ifCurrent)) return; + _shown = null; pending.value = null; - if (current != null) { - for (final path in current.filePaths) { - try { - final f = File(path); - if (f.existsSync()) f.deleteSync(); - } catch (_) { - // best-effort cleanup; OS will reclaim cache eventually - } + if (current != null) _deleteFiles(current); + unawaited(ReceiveSharingIntent.instance.reset()); + if (_flowDepth == 0) _drainQueue(); + } + + /// Drops every share, including a queued one — for sign-out. + void clearAll() { + final queued = _queued; + _queued = null; + _flowDepth = 0; + if (queued != null) _deleteFiles(queued); + clear(); + } + + void _onItems(List items) { + final share = _toPendingShare(items); + if (share == null) return; + // The iOS Share Extension opens the app twice per share, so the same + // payload can arrive again while it is still pending. Publishing it would + // re-run the navigation and pop the share that is in flight. + for (final live in [pending.value, _queued]) { + if (live != null && live.contentEquals(share)) { + _deleteFiles(share, keep: live.filePaths); + return; } } - unawaited(ReceiveSharingIntent.instance.reset()); + final claimed = _claim(share); + if (_flowDepth > 0) { + final replaced = _queued; + _queued = claimed; + if (replaced != null) _deleteFiles(replaced); + return; + } + _replaceCurrent(claimed); + } + + void _drainQueue() { + final queued = _queued; + if (queued == null) return; + _queued = null; + _replaceCurrent(queued); + } + + // No flow is running, so nothing uses the previous share's files anymore; + // its pages are popped when the new share is routed. + void _replaceCurrent(PendingShare share) { + final previous = pending.value; + _shown = null; + pending.value = share; + if (previous != null) _deleteFiles(previous); } PendingShare? _toPendingShare(List items) { @@ -96,7 +145,9 @@ class ShareIntentListener { case SharedMediaType.image: case SharedMediaType.video: case SharedMediaType.file: - files.add(item.path); + // A replayed share can point at temp copies deleted after the + // original share completed. + if (File(item.path).existsSync()) files.add(item.path); case SharedMediaType.text: case SharedMediaType.url: texts.add(item.path); @@ -109,4 +160,77 @@ class ShareIntentListener { receivedAt: DateTime.now(), ); } + + /// The Android plugin copies shared content to `cacheDir/`, + /// so a second share with an equally named file would overwrite the first + /// one's copy. Move owned copies into a folder of their own. iOS shares + /// already arrive in a per-share folder. + PendingShare _claim(PendingShare share) { + final cacheDir = _cacheDir; + if (!Platform.isAndroid || cacheDir == null || !share.hasFiles) { + return share; + } + final dir = Directory( + '$cacheDir/$_shareDirName/${share.receivedAt.microsecondsSinceEpoch}', + ); + final claimed = []; + for (final (i, path) in share.filePaths.indexed) { + if (!_isOwned(path)) { + claimed.add(path); + continue; + } + try { + dir.createSync(recursive: true); + final name = path.split(Platform.pathSeparator).last; + var target = '${dir.path}/$name'; + if (File(target).existsSync()) target = '${dir.path}/$i-$name'; + claimed.add(File(path).renameSync(target).path); + } catch (_) { + claimed.add(path); + } + } + return PendingShare( + filePaths: claimed, + text: share.text, + receivedAt: share.receivedAt, + sourcePaths: share.sourcePaths, + ); + } + + /// Only temp copies are deleted: on Android the plugin hands out the real + /// path for some providers (e.g. a file in Downloads), which must survive. + bool _isOwned(String path) { + if (Platform.isIOS) return true; + final cacheDir = _cacheDir; + return cacheDir != null && path.startsWith('$cacheDir/'); + } + + void _deleteFiles(PendingShare share, {List keep = const []}) { + for (final path in share.filePaths) { + if (keep.contains(path) || !_isOwned(path)) continue; + try { + final file = File(path); + if (file.existsSync()) file.deleteSync(); + final dir = file.parent; + if (dir.parent.path.endsWith('/$_shareDirName') && + dir.listSync().isEmpty) { + dir.deleteSync(); + } + } catch (_) { + // best-effort cleanup; OS will reclaim cache eventually + } + } + } + + // Shares whose flow never finished (process killed) leave their folder + // behind; the pending one of this process is not claimed yet. + void _sweepStaleAndroidShares() { + try { + final root = Directory('$_cacheDir/$_shareDirName'); + if (!root.existsSync()) return; + root.deleteSync(recursive: true); + } catch (_) { + // best-effort + } + } } diff --git a/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart index 8fe3ffd..0234055 100644 --- a/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart +++ b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart @@ -1,8 +1,11 @@ +import 'dart:async'; import 'dart:developer'; import 'package:hydrated_bloc/hydrated_bloc.dart'; import '../../../../../api/errors/error_mapper.dart'; +import '../../../../../api/errors/stale_session_exception.dart'; +import '../../../../../model/account_data.dart'; import '../../loadable_state/loadable_state.dart'; import '../../loadable_state/loading_error.dart'; import '../../repository/repository.dart'; @@ -114,6 +117,23 @@ abstract class LoadableHydratedBloc< add(Reset()); } + static const _sessionKey = #loadableSessionEpoch; + + /// Runs [body] tagged with the current session: events it adds, also from + /// its async continuations, are dropped once the account signed out, so a + /// late response of the previous account cannot refill the reset bloc. + R runInSession(R Function() body) => runZoned( + body, + zoneValues: {_sessionKey: AccountData().sessionEpoch}, + ); + + @override + void add(LoadableHydratedBlocEvent event) { + final epoch = Zone.current[_sessionKey]; + if (epoch is int && !AccountData().isCurrentSession(epoch)) return; + super.add(event); + } + TState? get innerState => state.data; TRepository get repo => _repository; @@ -126,29 +146,35 @@ abstract class LoadableHydratedBloc< /// Maps [e] through the shared error mapper and emits it as an [Error] event. /// Does not guard [isClosed] — callers decide whether a late error still /// applies. - void addLoadingError(Object e) => add( - Error( - LoadingError( - message: errorToUserMessage(e), - technicalDetails: errorToTechnicalDetails(e), - allowRetry: errorAllowsRetry(e), + void addLoadingError(Object e) { + // Belongs to a signed-out account; the current one loads on its own. + if (e is StaleSessionException) return; + add( + Error( + LoadingError( + message: errorToUserMessage(e), + technicalDetails: errorToTechnicalDetails(e), + allowRetry: errorAllowsRetry(e), + ), ), - ), - ); + ); + } void fetch() { log('Fetching data for ${TState.toString()}'); - gatherData() - .catchError((Object e) { - log('Error while fetching ${TState.toString()}: ${e.toString()}'); - // The bloc may have been closed before this async error landed; - // adding to a closed bloc throws, so swallow that case. - if (isClosed) return; - addLoadingError(e); - }) - .then((value) { - log('Fetch for ${TState.toString()} completed!'); - }); + runInSession( + () => gatherData() + .catchError((Object e) { + log('Error while fetching ${TState.toString()}: ${e.toString()}'); + // The bloc may have been closed before this async error landed; + // adding to a closed bloc throws, so swallow that case. + if (isClosed) return; + addLoadingError(e); + }) + .then((value) { + log('Fetch for ${TState.toString()} completed!'); + }), + ); } @override diff --git a/lib/state/app/modules/capabilities/bloc/capabilities_cubit.dart b/lib/state/app/modules/capabilities/bloc/capabilities_cubit.dart index 8290af6..1bc141b 100644 --- a/lib/state/app/modules/capabilities/bloc/capabilities_cubit.dart +++ b/lib/state/app/modules/capabilities/bloc/capabilities_cubit.dart @@ -5,6 +5,7 @@ import 'package:hydrated_bloc/hydrated_bloc.dart'; import '../../../../../api/demo/data/demo_capabilities.dart'; import '../../../../../api/demo/demo_mode.dart'; import '../../../../../api/marianumconnect/queries/get_capabilities/get_capabilities.dart'; +import '../../../../../model/account_data.dart'; import 'capabilities_state.dart'; /// Holds the current user's mobile capability flags. Hydrated so the last @@ -34,8 +35,12 @@ class CapabilitiesCubit extends HydratedCubit { emit(DemoCapabilities.state()); return; } + final epoch = AccountData().sessionEpoch; try { final response = await GetCapabilities().run(); + // A slow answer for the previous account must not decide the modules + // of the next one. + if (!AccountData().isCurrentSession(epoch)) return; emit( CapabilitiesState( viewForeignTimetables: response.viewForeignTimetables, diff --git a/lib/state/app/modules/chat/bloc/chat_bloc.dart b/lib/state/app/modules/chat/bloc/chat_bloc.dart index f8384b2..75dfd52 100644 --- a/lib/state/app/modules/chat/bloc/chat_bloc.dart +++ b/lib/state/app/modules/chat/bloc/chat_bloc.dart @@ -123,6 +123,15 @@ class ChatBloc /// No-op when the bloc has already moved on to a different token: when /// popping a stacked chat (B over A), A's didPopNext runs setToken(A) /// before B's dispose fires. + /// The chat view may still be popping when the sign-out resets this bloc, + /// so leaveChat would find no token and the long-poll would keep running. + @override + Future reset() { + _chatViewActive = false; + _stopLongPoll(); + return super.reset(); + } + void leaveChat(String fromToken) { if ((innerState?.currentToken ?? '') != fromToken) return; _chatViewActive = false; diff --git a/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart b/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart index a789254..caaa3e8 100644 --- a/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart +++ b/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart @@ -30,6 +30,13 @@ class ChatListBloc return super.close(); } + // The timer outlives the app shell; the next shell re-arms it after login. + @override + Future reset() { + setAutoRefreshInterval(null); + return super.reset(); + } + /// Silent refresh — explicit pull-to-refresh and tab-activation are non-silent. void setAutoRefreshInterval(Duration? interval) { if (interval == _autoRefreshInterval) return; diff --git a/lib/state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart b/lib/state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart index 6933eab..c2e34be 100644 --- a/lib/state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart +++ b/lib/state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart @@ -5,6 +5,7 @@ import 'package:hydrated_bloc/hydrated_bloc.dart'; import '../../../../../api/demo/data/demo_capabilities.dart'; import '../../../../../api/demo/demo_mode.dart'; import '../../../../../api/marianumcloud/capabilities/get_nextcloud_capabilities.dart'; +import '../../../../../model/account_data.dart'; import 'nextcloud_capabilities_state.dart'; /// Holds the current user's Nextcloud `files_sharing` capabilities. Hydrated so @@ -63,8 +64,12 @@ class NextcloudCapabilitiesCubit emit(DemoNextcloudCapabilities.state()); return; } + final epoch = AccountData().sessionEpoch; try { final caps = await GetNextcloudCapabilities().run(); + // A slow answer for the previous account must not decide the modules + // of the next one. + if (!AccountData().isCurrentSession(epoch)) return; emit( NextcloudCapabilitiesState( apiEnabled: caps.apiEnabled, diff --git a/lib/state/app/modules/timetable/bloc/timetable_bloc.dart b/lib/state/app/modules/timetable/bloc/timetable_bloc.dart index 8519361..e797e37 100644 --- a/lib/state/app/modules/timetable/bloc/timetable_bloc.dart +++ b/lib/state/app/modules/timetable/bloc/timetable_bloc.dart @@ -91,8 +91,10 @@ class TimetableBloc final current = innerState ?? fromNothing(); if (current.startDate == startDate && current.endDate == endDate) return; add(Emit((s) => s.copyWith(startDate: startDate, endDate: endDate))); - _loadCurrentWeek(startDate, endDate); - _prefetchAdjacentWeeks(startDate, endDate); + runInSession(() { + _loadCurrentWeek(startDate, endDate); + _prefetchAdjacentWeeks(startDate, endDate); + }); } void resetWeek() { diff --git a/lib/view/login/login_controller.dart b/lib/view/login/login_controller.dart index cb25b36..ee6c419 100644 --- a/lib/view/login/login_controller.dart +++ b/lib/view/login/login_controller.dart @@ -11,6 +11,7 @@ import '../../api/marianumconnect/auth/token_storage.dart'; import '../../api/marianumconnect/queries/auth_login/auth_login.dart'; import '../../api/marianumconnect/queries/auth_logout/auth_logout.dart'; import '../../model/account_data.dart'; +import '../../model/session_wipe.dart'; import '../../widget_data/widget_sync.dart'; /// Outcome of a login attempt. @@ -46,6 +47,10 @@ class LoginController extends ChangeNotifier { _errorDetails = null; notifyListeners(); + // The previous account's wipe runs deferred; racing it would let it + // clear what this login stores (widget job, caches). + await SessionWipe.done; + final user = username.trim().toLowerCase(); // Demo login: the prefix enters local demo mode, password ignored, no diff --git a/lib/view/pages/files/files_upload_dialog.dart b/lib/view/pages/files/files_upload_dialog.dart index 95502b7..bec671f 100644 --- a/lib/view/pages/files/files_upload_dialog.dart +++ b/lib/view/pages/files/files_upload_dialog.dart @@ -53,15 +53,6 @@ class _FilesUploadDialogState extends State { }).toList(); } - void showHttpErrorCode(int httpErrorCode) { - InfoDialog.show( - context, - 'Error code: $httpErrorCode', - title: 'Ein Fehler ist aufgetreten', - copyable: true, - ); - } - void _resetProgress() { _isUploading = false; _overallProgressValue = 0.0; @@ -221,14 +212,17 @@ class _FilesUploadDialogState extends State { return; } + if (!mounted) return; + // Stay on the dialog: popping here reported the partial result as a + // success to onUploadFinished and closed the error dialog right away. if (uploadTask.statusCode < 200 || uploadTask.statusCode > 299) { - setState(_resetProgress); - if (!mounted) return; - Navigator.of(context).pop(); - showHttpErrorCode(uploadTask.statusCode); - } else { - uploadetFilePaths.add(fullRemotePath); + _showUploadError( + 'Upload fehlgeschlagen für "$fileName" ' + '(Fehlercode ${uploadTask.statusCode}).', + ); + return; } + uploadetFilePaths.add(fullRemotePath); } setState(_resetProgress); diff --git a/lib/view/pages/share_intent/share_chat_picker.dart b/lib/view/pages/share_intent/share_chat_picker.dart index 905ebd9..10882a0 100644 --- a/lib/view/pages/share_intent/share_chat_picker.dart +++ b/lib/view/pages/share_intent/share_chat_picker.dart @@ -135,19 +135,24 @@ Future _externalShareFlow( PendingShare share, ) async { if (share.hasFiles) { - await ensureTalkShareFolder(); - if (!context.mounted) return; - await pushScreen( - context, - withNavBar: false, - screen: FilesUploadDialog( - filePaths: share.filePaths, - remotePath: talkShareFolder, - uniqueNames: true, - onUploadFinished: (uploaded) => - _afterExternalFilesUploaded(context, room, uploaded, share), - ), - ); + ShareIntentListener.instance.beginFlow(); + try { + await ensureTalkShareFolder(); + if (!context.mounted) return; + await pushScreen( + context, + withNavBar: false, + screen: FilesUploadDialog( + filePaths: share.filePaths, + remotePath: talkShareFolder, + uniqueNames: true, + onUploadFinished: (uploaded) => + _afterExternalFilesUploaded(context, room, uploaded, share), + ), + ); + } finally { + ShareIntentListener.instance.endFlow(); + } return; } if (share.hasText) { @@ -155,17 +160,28 @@ Future _externalShareFlow( } } +// Called synchronously when the upload dialog pops, so the flow is entered +// before the upload step above releases it. Future _afterExternalFilesUploaded( BuildContext context, GetRoomResponseObject room, List uploadedRemotePaths, PendingShare share, -) => _runShareFlow( - context, - action: () => - shareFilesToChat(token: room.token, remoteFilePaths: uploadedRemotePaths), - onSuccess: () => _setExternalDraftAndOpenChat(context, room, share), -); +) async { + ShareIntentListener.instance.beginFlow(); + try { + await _runShareFlow( + context, + action: () => shareFilesToChat( + token: room.token, + remoteFilePaths: uploadedRemotePaths, + ), + onSuccess: () => _setExternalDraftAndOpenChat(context, room, share), + ); + } finally { + ShareIntentListener.instance.endFlow(); + } +} /// Shared share-flow scaffolding: shows the blocking spinner, runs [action], /// maps failures to an error dialog (popping the spinner first), and invokes diff --git a/lib/view/pages/share_intent/share_folder_picker.dart b/lib/view/pages/share_intent/share_folder_picker.dart index 1c94522..e6f5fc9 100644 --- a/lib/view/pages/share_intent/share_folder_picker.dart +++ b/lib/view/pages/share_intent/share_folder_picker.dart @@ -206,16 +206,21 @@ Future _externalUploadFlow( List targetPath, PendingShare share, ) async { - await pushScreen( - context, - withNavBar: false, - screen: FilesUploadDialog( - filePaths: share.filePaths, - remotePath: targetPath.join('/'), - onUploadFinished: (_) => - _afterExternalUploaded(context, targetPath, share), - ), - ); + ShareIntentListener.instance.beginFlow(); + try { + await pushScreen( + context, + withNavBar: false, + screen: FilesUploadDialog( + filePaths: share.filePaths, + remotePath: targetPath.join('/'), + onUploadFinished: (_) => + _afterExternalUploaded(context, targetPath, share), + ), + ); + } finally { + ShareIntentListener.instance.endFlow(); + } } void _afterExternalUploaded( diff --git a/lib/widget_data/widget_publisher.dart b/lib/widget_data/widget_publisher.dart index 9cac9a5..e5233f6 100644 --- a/lib/widget_data/widget_publisher.dart +++ b/lib/widget_data/widget_publisher.dart @@ -3,6 +3,7 @@ import 'dart:developer'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import '../model/account_data.dart'; import '../state/app/modules/timetable/bloc/timetable_state.dart'; import '../storage/settings.dart'; import 'widget_data_mapper.dart'; @@ -24,6 +25,7 @@ class WidgetPublisher { Settings? settings, bool isTeacher = false, }) async { + final epoch = AccountData().sessionEpoch; try { final connectDouble = settings?.timetableSettings.connectDoubleLessons ?? true; @@ -58,6 +60,9 @@ class WidgetPublisher { connectDoubleLessons: connectDouble, showClassInsteadOfTeacher: isTeacher, ); + // A publish still running at sign-out would put the previous account's + // plan back onto the just cleared widget. + if (!AccountData().isCurrentSession(epoch)) return; await WidgetSync.writeDayData(dayData); await WidgetSync.writeWeekData(weekData); await WidgetSync.setLoggedIn(true); diff --git a/test/share_intent/pending_share_test.dart b/test/share_intent/pending_share_test.dart index 0e88987..a5e2987 100644 --- a/test/share_intent/pending_share_test.dart +++ b/test/share_intent/pending_share_test.dart @@ -62,5 +62,18 @@ void main() { test('empty shares compare equal', () { expect(_share().contentEquals(_share()), isTrue); }); + + test('relocated share still equals its raw delivery', () { + final raw = _share(filePaths: ['/cache/a.jpg']); + final claimed = PendingShare( + filePaths: ['/cache/share_intent/1/a.jpg'], + text: null, + receivedAt: DateTime(2026, 1, 1), + sourcePaths: raw.filePaths, + ); + + expect(claimed.contentEquals(raw), isTrue); + expect(raw.contentEquals(claimed), isTrue); + }); }); } From 4f31e68456ac977958dad4675845fe34a1ede5ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Thu, 24 Sep 2026 22:22:43 +0200 Subject: [PATCH 02/18] replaced generic "Event" label with substitution or lesson text --- .../timetable/data/lesson_type_label.dart | 11 ++++ .../pages/timetable/details/lesson_sheet.dart | 6 +- lib/widget_data/widget_data_mapper.dart | 9 ++- .../timetable/lesson_type_label_test.dart | 59 +++++++++++++++++++ 4 files changed, 81 insertions(+), 4 deletions(-) create mode 100644 test/view/timetable/lesson_type_label_test.dart diff --git a/lib/view/pages/timetable/data/lesson_type_label.dart b/lib/view/pages/timetable/data/lesson_type_label.dart index e5aa2c2..3b4f265 100644 --- a/lib/view/pages/timetable/data/lesson_type_label.dart +++ b/lib/view/pages/timetable/data/lesson_type_label.dart @@ -1,4 +1,5 @@ import '../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; +import 'lesson_labels.dart'; /// Derives a human-readable title from the Marianum-Connect `lessonType` for /// lessons that have no subject of their own (Aufsicht, Sprechstunde, …). @@ -10,10 +11,20 @@ class LessonTypeLabel { static String forEntry(McTimetableEntry lesson) { final base = baseLabel(lesson.lessonType); + if (base == fallback) { + final text = entryText(lesson); + if (text != null) return text; + } final room = (lesson.rooms.firstOrNull ?? '').trim(); return room.isEmpty ? base : '$room $base'; } + /// Webuntis-Freitext eines fachlosen Eintrags („Klassenfahrt 8a“), der + /// aussagekräftiger ist als das generische „Event“. + static String? entryText(McTimetableEntry lesson) => + collapseWhitespace(lesson.substitutionText) ?? + collapseWhitespace(lesson.lessonText); + /// Returns just the type wording without the room — useful when the caller /// renders the room separately (e.g. as a subtitle line). static String baseLabel(String lessonType) { diff --git a/lib/view/pages/timetable/details/lesson_sheet.dart b/lib/view/pages/timetable/details/lesson_sheet.dart index 5f0d5f4..bcd399c 100644 --- a/lib/view/pages/timetable/details/lesson_sheet.dart +++ b/lib/view/pages/timetable/details/lesson_sheet.dart @@ -10,6 +10,7 @@ import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; import '../../../../state/app/modules/timetable/bloc/timetable_state.dart'; import '../../../../widget/debug/debug_tile.dart'; import '../../../../widget/details_bottom_sheet.dart'; +import '../data/lesson_labels.dart'; import '../data/lesson_type_label.dart'; import '../subject_colors/subject_color_picker.dart'; @@ -226,10 +227,13 @@ class LessonSheet { } static List _optionalTextTiles(McTimetableEntry lesson) { + final sameText = + collapseWhitespace(lesson.lessonText) == + collapseWhitespace(lesson.substitutionText); return [ _textTile(Icons.info_outline, 'Info', lesson.infoText), _textTile(Icons.swap_horiz, 'Vertretungstext', lesson.substitutionText), - _textTile(Icons.subject, 'Stundentext', lesson.lessonText), + if (!sameText) _textTile(Icons.subject, 'Stundentext', lesson.lessonText), _textTile( Icons.category_outlined, 'Stundentyp', diff --git a/lib/widget_data/widget_data_mapper.dart b/lib/widget_data/widget_data_mapper.dart index af8a063..080a444 100644 --- a/lib/widget_data/widget_data_mapper.dart +++ b/lib/widget_data/widget_data_mapper.dart @@ -14,6 +14,7 @@ import '../view/pages/timetable/data/lesson_labels.dart'; import '../view/pages/timetable/data/lesson_merger.dart'; import '../view/pages/timetable/data/lesson_period_schedule.dart'; import '../view/pages/timetable/data/lesson_status.dart'; +import '../view/pages/timetable/data/lesson_type_label.dart'; import 'widget_data.dart'; class WidgetDataMapper { @@ -326,9 +327,11 @@ class WidgetDataMapper { LessonStatusClassifier.classify(lesson, start, end, now), ); final subjectShortRaw = lesson.subjects.firstOrNull?.trim() ?? ''; - // Webuntis sometimes ships subject-less entries (Wandertag etc.). Fall - // back to "Event" so the tile isn't just a dash. - final subjectShort = subjectShortRaw.isEmpty ? 'Event' : subjectShortRaw; + // Webuntis sometimes ships subject-less entries (Wandertag etc.). Prefer + // their free text, else "Event" so the tile isn't just a dash. + final subjectShort = subjectShortRaw.isEmpty + ? LessonTypeLabel.entryText(lesson) ?? LessonTypeLabel.fallback + : subjectShortRaw; String? subjectLong; if (subjects != null && subjectShortRaw.isNotEmpty) { subjectLong = subjects.result diff --git a/test/view/timetable/lesson_type_label_test.dart b/test/view/timetable/lesson_type_label_test.dart new file mode 100644 index 0000000..86b0f2c --- /dev/null +++ b/test/view/timetable/lesson_type_label_test.dart @@ -0,0 +1,59 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; +import 'package:marianum_mobile/view/pages/timetable/data/lesson_type_label.dart'; + +McTimetableEntry _entry({ + String lessonType = 'LESSON', + String? substitutionText, + String? lessonText, +}) => McTimetableEntry( + id: 1, + date: DateTime(2026, 5, 4), + startTime: DateTime(1970, 1, 1, 8), + endTime: DateTime(1970, 1, 1, 15), + subjects: const [], + teachers: const [], + rooms: const ['A101'], + classNames: const ['8a'], + lessonType: lessonType, + status: 'REGULAR', + substitutionText: substitutionText, + lessonText: lessonText, + infoText: null, +); + +void main() { + group('LessonTypeLabel.forEntry', () { + test('prefers the substitution text', () { + expect( + LessonTypeLabel.forEntry( + _entry(substitutionText: 'Klassenfahrt 8a', lessonText: 'Anderes'), + ), + 'Klassenfahrt 8a', + ); + }); + + test('falls back to the lesson text', () { + expect( + LessonTypeLabel.forEntry(_entry(lessonText: ' Wandertag\n8a ')), + 'Wandertag 8a', + ); + }); + + test('falls back to "Event" with room when no text is set', () { + expect( + LessonTypeLabel.forEntry(_entry(substitutionText: ' ')), + 'A101 Event', + ); + }); + + test('keeps known lesson types over free text', () { + expect( + LessonTypeLabel.forEntry( + _entry(lessonType: 'BREAK_SUPERVISION', lessonText: 'Hof'), + ), + 'A101 Aufsicht', + ); + }); + }); +} From de866b12b42f81cc7d17450ee7e153cd7a53008f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Thu, 24 Sep 2026 22:33:00 +0200 Subject: [PATCH 03/18] fixed stalled requests after app resume --- .../autocomplete/autocomplete_api.dart | 8 +++++++- lib/api/marianumcloud/talk/talk_api.dart | 6 +++++- lib/api/marianumconnect/marianumconnect_api.dart | 10 ++++++++++ lib/app.dart | 2 ++ lib/state/app/modules/chat/bloc/chat_bloc.dart | 15 ++++++++++----- 5 files changed, 34 insertions(+), 7 deletions(-) diff --git a/lib/api/marianumcloud/autocomplete/autocomplete_api.dart b/lib/api/marianumcloud/autocomplete/autocomplete_api.dart index 1423c43..0394871 100644 --- a/lib/api/marianumcloud/autocomplete/autocomplete_api.dart +++ b/lib/api/marianumcloud/autocomplete/autocomplete_api.dart @@ -4,6 +4,7 @@ import 'package:http/http.dart' as http; import '../../../model/endpoint_data.dart'; import '../../errors/server_exception.dart'; +import '../../http_errors.dart'; import '../nextcloud_ocs.dart'; import 'autocomplete_response.dart'; @@ -30,7 +31,12 @@ class AutocompleteApi { 'limit': '10', }, ); - final response = await http.get(uri, headers: NextcloudOcs.headers()); + final response = (await sendGuarded( + 'Autocomplete $uri', + () => http + .get(uri, headers: NextcloudOcs.headers()) + .timeout(const Duration(seconds: 20)), + ))!; if (response.statusCode != HttpStatus.ok) { throw ServerException( statusCode: response.statusCode, diff --git a/lib/api/marianumcloud/talk/talk_api.dart b/lib/api/marianumcloud/talk/talk_api.dart index f8c3e60..78282bd 100644 --- a/lib/api/marianumcloud/talk/talk_api.dart +++ b/lib/api/marianumcloud/talk/talk_api.dart @@ -12,6 +12,10 @@ import '../nextcloud_ocs.dart'; import 'talk_error.dart'; abstract class TalkApi { + // package:http has no timeout of its own; a request stuck on a dead + // connection (e.g. one started right before a suspend) would spin forever. + static const Duration _timeout = Duration(seconds: 30); + String path; ApiParams? body; Map? headers; @@ -35,7 +39,7 @@ abstract class TalkApi { final data = await sendGuarded( 'Talk $endpoint', - () => request(endpoint, body, mergedHeaders), + () => request(endpoint, body, mergedHeaders)?.timeout(_timeout), ); if (data == null) { throw const NetworkException( diff --git a/lib/api/marianumconnect/marianumconnect_api.dart b/lib/api/marianumconnect/marianumconnect_api.dart index 5030ea0..ad38c3c 100644 --- a/lib/api/marianumconnect/marianumconnect_api.dart +++ b/lib/api/marianumconnect/marianumconnect_api.dart @@ -16,6 +16,16 @@ class MarianumConnectApi { static Dio dio() => _instance; + /// Drops the pooled keep-alive sockets. After a suspend the OS or a NAT + /// has usually killed them already, but dart:io only notices once a request + /// is written into one — which then hangs until [_receiveTimeout]. + /// In-flight requests finish on the old adapter. + static void resetConnections() { + final stale = _instance.httpClientAdapter; + _instance.httpClientAdapter = HttpClientAdapter(); + stale.close(); + } + /// A fresh dio with the standard JSON options but no interceptors — used by /// the auth queries (login/verify) that must bypass the bearer/demo /// interceptors to avoid a re-auth loop. diff --git a/lib/app.dart b/lib/app.dart index c346f69..c8ae5d1 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; +import 'api/marianumconnect/marianumconnect_api.dart'; import 'api/marianumconnect/queries/get_breakers/get_breakers_response.dart'; import 'api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart'; import 'main.dart'; @@ -98,6 +99,7 @@ class _AppState extends State with WidgetsBindingObserver { void didChangeAppLifecycleState(AppLifecycleState state) { log('AppLifecycle: $state'); if (state == AppLifecycleState.resumed) { + MarianumConnectApi.resetConnections(); _reportTelemetry(); Debouncer.throttle('appLifecycleState', const Duration(seconds: 10), () { if (!mounted) return; diff --git a/lib/state/app/modules/chat/bloc/chat_bloc.dart b/lib/state/app/modules/chat/bloc/chat_bloc.dart index 75dfd52..a36acfc 100644 --- a/lib/state/app/modules/chat/bloc/chat_bloc.dart +++ b/lib/state/app/modules/chat/bloc/chat_bloc.dart @@ -23,6 +23,9 @@ class ChatBloc final ChatListBloc? _chatListBloc; String? _pollingToken; + // A loop parked in its request survives stop+start for the same token + // (pause/resume); the generation retires it so only one loop polls. + int _pollGeneration = 0; int _backoffMs = 0; int _lastKnownMessageId = 0; bool _appResumed = true; @@ -265,23 +268,25 @@ class ChatBloc _pollingToken = token; _backoffMs = 0; _lastKnownMessageId = _maxMessageId(innerState?.chatResponse); - unawaited(_pollLoop(token)); + unawaited(_pollLoop(token, ++_pollGeneration)); } void _stopLongPoll() { _pollingToken = null; + _pollGeneration++; _backoffMs = 0; } - Future _pollLoop(String token) async { - while (_pollingToken == token && !isClosed) { + Future _pollLoop(String token, int generation) async { + bool active() => generation == _pollGeneration && !isClosed; + while (active()) { try { final response = await LongPollChat( chatToken: token, lastKnownMessageId: _lastKnownMessageId, ).run(); - if (_pollingToken != token || isClosed) return; + if (!active()) return; _backoffMs = 0; if (response == null) continue; @@ -305,7 +310,7 @@ class ChatBloc _chatListBloc?.markRoomAsRead(token, _lastKnownMessageId); } } on Object catch (e) { - if (_pollingToken != token || isClosed) return; + if (!active()) return; log('LongPoll error for $token: $e'); _backoffMs = _backoffMs == 0 ? 2000 : math.min(_backoffMs * 2, 30000); await Future.delayed(Duration(milliseconds: _backoffMs)); From e21560ed3d8b89e3b25d5df983a1d106756e8d5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Thu, 24 Sep 2026 23:33:13 +0200 Subject: [PATCH 04/18] added teacher-focused lesson sheet with direct class and teacher timetable links --- .../timetable_get_week_response.dart | 8 + .../timetable_get_week_response.g.dart | 8 + lib/main.dart | 19 +- lib/state/app/modules/app_modules.dart | 21 +++ lib/storage/modules_settings.dart | 4 + lib/storage/modules_settings.g.dart | 2 + .../pages/timetable/data/lesson_merger.dart | 1 + .../data/timetable_appointment_factory.dart | 14 +- .../appointment_details_dispatcher.dart | 5 + .../pages/timetable/details/lesson_sheet.dart | 177 ++++++++++++++++-- lib/view/pages/timetable/timetable.dart | 22 ++- lib/widget_data/widget_data_mapper.dart | 11 +- test/state/app_modules_order_test.dart | 43 +++++ .../timetable/teacher_class_label_test.dart | 4 +- 14 files changed, 306 insertions(+), 33 deletions(-) diff --git a/lib/api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart b/lib/api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart index 48bcc7e..e5b5011 100644 --- a/lib/api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart +++ b/lib/api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart @@ -10,12 +10,17 @@ class McTimetableTeacher { final String displayName; final String? originalShortName; final String? originalDisplayName; + // Webuntis element ids; null from servers that predate them. + final int? id; + final int? originalId; McTimetableTeacher({ required this.shortName, required this.displayName, this.originalShortName, this.originalDisplayName, + this.id, + this.originalId, }); factory McTimetableTeacher.fromJson(Map json) => @@ -45,6 +50,8 @@ class McTimetableEntry { final String? substitutionText; final String? lessonText; final String? infoText; + // Index-aligned with [classNames]; null from servers that predate it. + final List? classIds; McTimetableEntry({ required this.id, @@ -60,6 +67,7 @@ class McTimetableEntry { required this.substitutionText, required this.lessonText, required this.infoText, + this.classIds, }); factory McTimetableEntry.fromJson(Map json) => diff --git a/lib/api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.g.dart b/lib/api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.g.dart index 96edde4..d32bd40 100644 --- a/lib/api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.g.dart +++ b/lib/api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.g.dart @@ -12,6 +12,8 @@ McTimetableTeacher _$McTimetableTeacherFromJson(Map json) => displayName: json['displayName'] as String, originalShortName: json['originalShortName'] as String?, originalDisplayName: json['originalDisplayName'] as String?, + id: (json['id'] as num?)?.toInt(), + originalId: (json['originalId'] as num?)?.toInt(), ); Map _$McTimetableTeacherToJson(McTimetableTeacher instance) => @@ -20,6 +22,8 @@ Map _$McTimetableTeacherToJson(McTimetableTeacher instance) => 'displayName': instance.displayName, 'originalShortName': instance.originalShortName, 'originalDisplayName': instance.originalDisplayName, + 'id': instance.id, + 'originalId': instance.originalId, }; McTimetableEntry _$McTimetableEntryFromJson(Map json) => @@ -43,6 +47,9 @@ McTimetableEntry _$McTimetableEntryFromJson(Map json) => substitutionText: json['substitutionText'] as String?, lessonText: json['lessonText'] as String?, infoText: json['infoText'] as String?, + classIds: (json['classIds'] as List?) + ?.map((e) => (e as num).toInt()) + .toList(), ); Map _$McTimetableEntryToJson(McTimetableEntry instance) => @@ -60,6 +67,7 @@ Map _$McTimetableEntryToJson(McTimetableEntry instance) => 'substitutionText': instance.substitutionText, 'lessonText': instance.lessonText, 'infoText': instance.infoText, + 'classIds': instance.classIds, }; TimetableGetWeekResponse _$TimetableGetWeekResponseFromJson( diff --git a/lib/main.dart b/lib/main.dart index 2eafc98..235cb10 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -36,6 +36,7 @@ import 'routing/app_routes.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/app_modules.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'; @@ -295,7 +296,9 @@ class _MainState extends State
{ unawaited( context.read().load().then((_) { if (!mounted) return; - _syncPush(settingsCubit, context.read()); + final capabilities = context.read(); + _syncPush(settingsCubit, capabilities); + _applyRoleDefaults(settingsCubit, capabilities); }), ); unawaited(context.read().load()); @@ -331,6 +334,19 @@ class _MainState extends State
{ ); } + /// userType is only known once capabilities are loaded. + void _applyRoleDefaults( + SettingsCubit settings, + CapabilitiesCubit capabilities, + ) { + if (AppModule.applyTeacherDefaults( + settings.val().modulesSettings, + isTeacher: capabilities.isTeacher, + )) { + settings.val(write: true); + } + } + /// 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). @@ -424,6 +440,7 @@ class _MainState extends State
{ capabilitiesCubit.load().then((_) { if (!mounted) return; _syncPush(settingsCubit, capabilitiesCubit); + _applyRoleDefaults(settingsCubit, capabilitiesCubit); }), ); unawaited( diff --git a/lib/state/app/modules/app_modules.dart b/lib/state/app/modules/app_modules.dart index 59c8c67..97e94fa 100644 --- a/lib/state/app/modules/app_modules.dart +++ b/lib/state/app/modules/app_modules.dart @@ -188,6 +188,27 @@ class AppModule { return order; } + /// Modules that are of no use to teachers and start out hidden for them. + static const Set hiddenForTeachersByDefault = { + Modules.absenceReport, + }; + + /// Hides [hiddenForTeachersByDefault] once for teacher accounts; the teacher + /// can re-enable them in the module settings. Returns whether [settings] + /// changed. + static bool applyTeacherDefaults( + ModulesSettings settings, { + required bool isTeacher, + }) { + if (!isTeacher || settings.teacherDefaultsApplied) return false; + settings.hiddenModules = { + ...settings.hiddenModules, + ...hiddenForTeachersByDefault, + }.toList(); + settings.teacherDefaultsApplied = true; + return true; + } + // The settings list displays a capability-filtered subset, so reorder // indices refer to that subset; the move is applied there and merged back // into the full order (non-displayed modules keep their relative slots). diff --git a/lib/storage/modules_settings.dart b/lib/storage/modules_settings.dart index 0706b9c..a3bc941 100644 --- a/lib/storage/modules_settings.dart +++ b/lib/storage/modules_settings.dart @@ -10,12 +10,16 @@ class ModulesSettings { List hiddenModules; bool autoFillBottomBar; int fixedBottomBarSlots; + // Set once the teacher module defaults were applied, so a module the teacher + // re-enables afterwards stays visible. + bool teacherDefaultsApplied; ModulesSettings({ required this.moduleOrder, required this.hiddenModules, this.autoFillBottomBar = true, this.fixedBottomBarSlots = 3, + this.teacherDefaultsApplied = false, }); factory ModulesSettings.fromJson(Map json) => diff --git a/lib/storage/modules_settings.g.dart b/lib/storage/modules_settings.g.dart index 20ea811..fe9b776 100644 --- a/lib/storage/modules_settings.g.dart +++ b/lib/storage/modules_settings.g.dart @@ -16,6 +16,7 @@ ModulesSettings _$ModulesSettingsFromJson(Map json) => .toList(), autoFillBottomBar: json['autoFillBottomBar'] as bool? ?? true, fixedBottomBarSlots: (json['fixedBottomBarSlots'] as num?)?.toInt() ?? 3, + teacherDefaultsApplied: json['teacherDefaultsApplied'] as bool? ?? false, ); Map _$ModulesSettingsToJson( @@ -27,6 +28,7 @@ Map _$ModulesSettingsToJson( .toList(), 'autoFillBottomBar': instance.autoFillBottomBar, 'fixedBottomBarSlots': instance.fixedBottomBarSlots, + 'teacherDefaultsApplied': instance.teacherDefaultsApplied, }; const _$ModulesEnumMap = { diff --git a/lib/view/pages/timetable/data/lesson_merger.dart b/lib/view/pages/timetable/data/lesson_merger.dart index bb5ab06..03f1e28 100644 --- a/lib/view/pages/timetable/data/lesson_merger.dart +++ b/lib/view/pages/timetable/data/lesson_merger.dart @@ -73,5 +73,6 @@ class LessonMerger { substitutionText: source.substitutionText, lessonText: source.lessonText, infoText: source.infoText, + classIds: source.classIds, ); } diff --git a/lib/view/pages/timetable/data/timetable_appointment_factory.dart b/lib/view/pages/timetable/data/timetable_appointment_factory.dart index 307ef28..c7ea181 100644 --- a/lib/view/pages/timetable/data/timetable_appointment_factory.dart +++ b/lib/view/pages/timetable/data/timetable_appointment_factory.dart @@ -240,12 +240,14 @@ class TimetableAppointmentFactory { String _locationLabel(McTimetableEntry lesson) { final roomName = collapseWhitespace(lesson.rooms.firstOrNull) ?? 'Unbekannt'; - // Klassenlose Einträge (Aufsichten etc.) fallen auf den Lehrer zurück. - final secondLine = - (showClassInsteadOfTeacher ? lesson.classLabel : null) ?? - _teacherLabel(lesson.teachers.firstOrNull) ?? - 'Unbekannt'; - return '$roomName\n$secondLine'; + if (showClassInsteadOfTeacher) { + // Klassenlose Einträge (Aufsichten etc.) zeigen nur den Raum — die + // Lehrkraft wäre hier die Plan-Inhaberin selbst. + final classLabel = lesson.classLabel; + return classLabel == null ? roomName : '$roomName\n$classLabel'; + } + final teacher = _teacherLabel(lesson.teachers.firstOrNull) ?? 'Unbekannt'; + return '$roomName\n$teacher'; } /// Backend serves teachers with their full display name ("Stefan Müller"), diff --git a/lib/view/pages/timetable/details/appointment_details_dispatcher.dart b/lib/view/pages/timetable/details/appointment_details_dispatcher.dart index de8edd0..c02cc08 100644 --- a/lib/view/pages/timetable/details/appointment_details_dispatcher.dart +++ b/lib/view/pages/timetable/details/appointment_details_dispatcher.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart'; +import '../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart'; import '../../../../state/app/modules/timetable/bloc/timetable_state.dart'; import '../data/arbitrary_appointment.dart'; import 'custom_event_sheet.dart'; @@ -12,6 +13,8 @@ class AppointmentDetailsDispatcher { TimetableState? state, Appointment appointment, { bool canEditSubjectColor = false, + bool teacherPlan = false, + void Function(TimetableElementRef element)? onOpenElement, }) { final id = appointment.id; if (id is! ArbitraryAppointment) return; @@ -23,6 +26,8 @@ class AppointmentDetailsDispatcher { appointment, entry, canEditSubjectColor: canEditSubjectColor, + teacherPlan: teacherPlan, + onOpenElement: onOpenElement, ), custom: (event) => CustomEventSheet.show(context, event), ); diff --git a/lib/view/pages/timetable/details/lesson_sheet.dart b/lib/view/pages/timetable/details/lesson_sheet.dart index bcd399c..d402d04 100644 --- a/lib/view/pages/timetable/details/lesson_sheet.dart +++ b/lib/view/pages/timetable/details/lesson_sheet.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart'; +import '../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart'; import '../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; import '../../../../extensions/date_time.dart'; import '../../../../extensions/text.dart'; @@ -21,6 +22,8 @@ class LessonSheet { Appointment appointment, McTimetableEntry lesson, { bool canEditSubjectColor = false, + bool teacherPlan = false, + void Function(TimetableElementRef element)? onOpenElement, }) { if (state == null) return; @@ -72,7 +75,7 @@ class LessonSheet { ) : null, ), - children: (_) => [ + children: (sheetContext) => [ ListTile( leading: const Icon(Icons.notifications_active), title: Text('Status: ${_statusLabel(lesson.status)}'), @@ -88,13 +91,14 @@ class LessonSheet { .toList(), ), _roomTile(context, lesson), - _teacherTile(context, lesson), - if (lesson.classNames.isNotEmpty) - _listTile( - icon: Icons.people, - label: lesson.classNames.length == 1 ? 'Klasse' : 'Klassen', - entries: lesson.classNames.map(_line).toList(), - ), + ..._peopleTiles( + context, + lesson, + opener: onOpenElement == null + ? null + : _ElementOpener(sheetContext, onOpenElement), + teacherPlan: teacherPlan, + ), ..._optionalTextTiles(lesson), DebugTile(context).jsonData(lesson.toJson()), ], @@ -128,7 +132,59 @@ class LessonSheet { ); } - static Widget _teacherTile(BuildContext context, McTimetableEntry lesson) { + // Aus Lehrersicht ist die Klasse die relevante Angabe, die Lehrkraft ist + // meist die Plan-Inhaberin selbst — daher dort Klasse zuerst. + static List _peopleTiles( + BuildContext context, + McTimetableEntry lesson, { + required _ElementOpener? opener, + required bool teacherPlan, + }) { + final classTile = _classTile(lesson, opener); + final teacherTile = _teacherTile( + context, + lesson, + opener, + teacherPlan: teacherPlan, + ); + return teacherPlan ? [?classTile, teacherTile] : [teacherTile, ?classTile]; + } + + static Widget? _classTile(McTimetableEntry lesson, _ElementOpener? opener) { + final names = lesson.classNames; + if (names.isEmpty) return null; + const tooltip = 'Stundenplan der Klasse öffnen'; + final ids = lesson.classIds?.length == names.length + ? lesson.classIds + : null; + TimetableElementRef? element(int i) => ids == null + ? null + : (type: TimetableElementType.schoolClass, id: ids[i], label: names[i]); + + return _listTile( + icon: Icons.people, + label: names.length == 1 ? 'Klasse' : 'Klassen', + entries: names.map(_line).toList(), + trailing: names.length == 1 + ? opener?.button(tooltip: tooltip, element: element(0)) + : null, + wrapRow: (i, row) => _openable(opener, row, tooltip, element(i)), + ); + } + + static Widget _openable( + _ElementOpener? opener, + Widget label, + String tooltip, + TimetableElementRef? element, + ) => opener?.row(label: label, tooltip: tooltip, element: element) ?? label; + + static Widget _teacherTile( + BuildContext context, + McTimetableEntry lesson, + _ElementOpener? opener, { + required bool teacherPlan, + }) { if (lesson.teachers.isEmpty) { return const ListTile( leading: Icon(Icons.person), @@ -146,12 +202,17 @@ class LessonSheet { } final label = teachers.length == 1 ? 'Lehrkraft' : 'Lehrkräfte'; + const tooltip = 'Stundenplan der Lehrkraft öffnen'; // Einzelne, reguläre Lehrkraft kompakt in der Titelzeile. if (teachers.length == 1 && teachers.first.isPlain) { return ListTile( leading: const Icon(Icons.person), title: Text('$label: ${teachers.first.after}'), + // Im Lehrerplan ist das die Plan-Inhaberin selbst. + trailing: teacherPlan + ? null + : opener?.button(tooltip: tooltip, element: teachers.first.target), ); } @@ -160,7 +221,10 @@ class LessonSheet { title: Text(label), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [for (final t in teachers) t.buildRow(context)], + children: [ + for (final t in teachers) + _openable(opener, t.buildRow(context), tooltip, t.target), + ], ), ); } @@ -207,6 +271,7 @@ class LessonSheet { required String label, required List entries, Widget? trailing, + Widget Function(int index, Widget row)? wrapRow, }) { if (entries.length == 1) { return ListTile( @@ -220,7 +285,10 @@ class LessonSheet { title: Text(label), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: entries.map(Text.new).toList(), + children: [ + for (final (i, entry) in entries.indexed) + wrapRow?.call(i, Text(entry)) ?? Text(entry), + ], ), trailing: trailing, ); @@ -333,7 +401,15 @@ class _TeacherDisplay { /// Aktuelle Lehrkraft. Leer bei ersatzlosem Entfall. final String after; - const _TeacherDisplay._({this.before = '', this.after = ''}); + /// Lehrkraft, deren Stundenplan sich öffnen lässt: die aktuelle, bei + /// ersatzlosem Entfall die ursprüngliche. Null, wenn beide unbekannt sind. + final TimetableElementRef? target; + + const _TeacherDisplay._({ + this.before = '', + this.after = '', + this.target, + }); bool get isPlain => before.isEmpty; @@ -345,14 +421,32 @@ class _TeacherDisplay { t.originalShortName ?? '', t.originalDisplayName ?? '', ); + final target = _target(t, currentKnown: current.isNotEmpty); // Kein (abweichendes) Original → reguläre Lehrkraft. if (original.isEmpty || original == current) { - return _TeacherDisplay._(after: current.isEmpty ? '?' : current); + return _TeacherDisplay._( + after: current.isEmpty ? '?' : current, + target: target, + ); } // Original vorhanden → ersetzte/entfallende Lehrkraft vorn, aktuelle // Lehrkraft (falls vorhanden) als Ersatz dahinter. - return _TeacherDisplay._(before: original, after: current); + return _TeacherDisplay._( + before: original, + after: current, + target: target, + ); + } + + static TimetableElementRef? _target( + McTimetableTeacher t, { + required bool currentKnown, + }) { + final id = currentKnown ? t.id : t.originalId; + if (id == null) return null; + final label = currentKnown ? t.displayName : t.originalDisplayName; + return (type: TimetableElementType.teacher, id: id, label: label ?? ''); } /// Eine Bullet-Zeile je Lehrkraft: ersetzte Person durchgestrichen, bei @@ -404,3 +498,58 @@ class _TeacherDisplay { return short; } } + +/// Baut die Bedienelemente, die aus dem Sheet heraus einen fremden +/// Stundenplan öffnen. Ohne Element-Id (älterer Server) entfallen sie. +class _ElementOpener { + static const IconData icon = Icons.calendar_view_week_outlined; + + final BuildContext sheetContext; + final void Function(TimetableElementRef element) onOpen; + + const _ElementOpener(this.sheetContext, this.onOpen); + + void _open(TimetableElementRef element) { + Navigator.of(sheetContext).pop(); + onOpen(element); + } + + /// Trailing-Button für einzeilige Tiles, analog zum Raumplan-Button. + Widget? button({ + required String tooltip, + required TimetableElementRef? element, + }) { + if (element == null) return null; + return IconButton( + icon: const Icon(icon), + tooltip: tooltip, + onPressed: () => _open(element), + ); + } + + /// Kompakte, vollständig antippbare Zeile für Aufzählungen — ein + /// 48px-Button pro Eintrag würde die Liste auseinanderziehen. + Widget row({ + required Widget label, + required String tooltip, + required TimetableElementRef? element, + }) { + if (element == null) return label; + final muted = Theme.of(sheetContext).colorScheme.onSurfaceVariant; + return Semantics( + button: true, + label: tooltip, + child: InkWell( + onTap: () => _open(element), + borderRadius: BorderRadius.circular(6), + child: Row( + children: [ + Expanded(child: label), + const SizedBox(width: 8), + Icon(icon, size: 18, color: muted), + ], + ), + ), + ); + } +} diff --git a/lib/view/pages/timetable/timetable.dart b/lib/view/pages/timetable/timetable.dart index 46f4f74..366942a 100644 --- a/lib/view/pages/timetable/timetable.dart +++ b/lib/view/pages/timetable/timetable.dart @@ -46,6 +46,10 @@ class _TimetableState extends State { if (guardDemoAction(context)) return; final ref = await AppRoutes.openElementPicker(context); if (!mounted || ref == null) return; + _openElement(ref); + } + + void _openElement(TimetableElementRef ref) { setState(() => _selected = ref); } @@ -103,6 +107,7 @@ class _TimetableState extends State { final atToday = innerState != null && _isOnInitialWeek(innerState); final capabilities = context.watch(); final canViewForeign = capabilities.canViewForeignTimetables; + final teacherPlan = capabilities.isTeacher; return Scaffold( appBar: AppBar( // Der Kalender scrollt nicht (nur ziehen/reloaden), aber seine internen @@ -162,10 +167,12 @@ class _TimetableState extends State { state, apt, canEditSubjectColor: true, + teacherPlan: teacherPlan, + onOpenElement: canViewForeign ? _openElement : null, ), onCreateEvent: _onCreateEventAt, customEvents: state.customEvents?.events ?? const [], - showClassInsteadOfTeacher: capabilities.isTeacher, + showClassInsteadOfTeacher: teacherPlan, ), ), ); @@ -179,6 +186,7 @@ class _TimetableState extends State { final canViewForeign = context .watch() .canViewForeignTimetables; + final teacherPlan = selected.type == TimetableElementType.teacher; return Scaffold( appBar: AppBar( // Siehe _buildOwnPlan: den scroll-under-Farbwechsel unterdrücken, weil @@ -214,11 +222,15 @@ class _TimetableState extends State { key: _calendarKey, state: state, onWeekChanged: bloc.changeWeek, - onAppointmentTap: (apt) => - AppointmentDetailsDispatcher.show(context, state, apt), + onAppointmentTap: (apt) => AppointmentDetailsDispatcher.show( + context, + state, + apt, + teacherPlan: teacherPlan, + onOpenElement: canViewForeign ? _openElement : null, + ), customEvents: const [], - showClassInsteadOfTeacher: - selected.type == TimetableElementType.teacher, + showClassInsteadOfTeacher: teacherPlan, ), ), ), diff --git a/lib/widget_data/widget_data_mapper.dart b/lib/widget_data/widget_data_mapper.dart index 080a444..eb30bda 100644 --- a/lib/widget_data/widget_data_mapper.dart +++ b/lib/widget_data/widget_data_mapper.dart @@ -350,11 +350,12 @@ class WidgetDataMapper { } final teacher = lesson.teachers.firstOrNull; // Lehrerpläne: Klasse in den Teacher-Slot mappen, damit die nativen - // Renderer unverändert bleiben. Klassenlose Einträge (Aufsichten) behalten - // den Lehrer als Fallback. - final classLabel = showClassInsteadOfTeacher ? lesson.classLabel : null; - final teacherName = classLabel ?? teacher?.shortName; - final originalTeacher = classLabel != null + // Renderer unverändert bleiben. Klassenlose Einträge (Aufsichten) lassen + // den Slot leer — die Lehrkraft wäre die Plan-Inhaberin selbst. + final teacherName = showClassInsteadOfTeacher + ? lesson.classLabel + : teacher?.shortName; + final originalTeacher = showClassInsteadOfTeacher ? null : teacher?.originalShortName; return WidgetLesson( diff --git a/test/state/app_modules_order_test.dart b/test/state/app_modules_order_test.dart index 7732c79..74eb134 100644 --- a/test/state/app_modules_order_test.dart +++ b/test/state/app_modules_order_test.dart @@ -92,4 +92,47 @@ void main() { ); }); }); + + group('applyTeacherDefaults', () { + test('hides the absence report once for teachers', () { + final settings = settingsWith(Modules.values); + + expect(AppModule.applyTeacherDefaults(settings, isTeacher: true), isTrue); + expect(settings.hiddenModules, [Modules.absenceReport]); + expect(settings.teacherDefaultsApplied, isTrue); + }); + + test('keeps a module the teacher re-enabled afterwards', () { + final settings = settingsWith(Modules.values); + AppModule.applyTeacherDefaults(settings, isTeacher: true); + settings.hiddenModules.remove(Modules.absenceReport); + + expect( + AppModule.applyTeacherDefaults(settings, isTeacher: true), + isFalse, + ); + expect(settings.hiddenModules, isEmpty); + }); + + test('leaves non-teachers untouched', () { + final settings = settingsWith(Modules.values); + + expect( + AppModule.applyTeacherDefaults(settings, isTeacher: false), + isFalse, + ); + expect(settings.hiddenModules, isEmpty); + expect(settings.teacherDefaultsApplied, isFalse); + }); + + test('does not duplicate an already hidden module', () { + final settings = ModulesSettings( + moduleOrder: Modules.values, + hiddenModules: [Modules.absenceReport, Modules.files], + ); + AppModule.applyTeacherDefaults(settings, isTeacher: true); + + expect(settings.hiddenModules, [Modules.absenceReport, Modules.files]); + }); + }); } diff --git a/test/view/timetable/teacher_class_label_test.dart b/test/view/timetable/teacher_class_label_test.dart index f6cf531..e56d9fc 100644 --- a/test/view/timetable/teacher_class_label_test.dart +++ b/test/view/timetable/teacher_class_label_test.dart @@ -65,10 +65,10 @@ void main() { ); }); - test('falls back to the teacher when the entry has no class', () { + test('shows only the room when the entry has no class', () { expect( _location(showClassInsteadOfTeacher: true, classNames: const []), - 'A101\nMüller', + 'A101', ); }); }); From ef7d17033d5b077bb387fd22febbed3bdb3ec1f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Fri, 25 Sep 2026 17:04:10 +0200 Subject: [PATCH 05/18] added talk notification levels for chats --- .../talk/actions/talk_actions.dart | 19 ++++ .../talk/room/get_room_response.dart | 6 ++ .../talk/room/notification_level.dart | 27 ++++++ .../chat_list/bloc/chat_list_bloc.dart | 13 +++ lib/view/pages/talk/details/chat_info.dart | 19 ++++ lib/view/pages/talk/widgets/chat_tile.dart | 22 +++++ .../widgets/notification_level_sheet.dart | 95 +++++++++++++++++++ lib/widget/a11y/a11y_labels.dart | 1 + test/talk/notification_level_test.dart | 79 +++++++++++++++ 9 files changed, 281 insertions(+) create mode 100644 lib/api/marianumcloud/talk/room/notification_level.dart create mode 100644 lib/view/pages/talk/widgets/notification_level_sheet.dart create mode 100644 test/talk/notification_level_test.dart diff --git a/lib/api/marianumcloud/talk/actions/talk_actions.dart b/lib/api/marianumcloud/talk/actions/talk_actions.dart index acf4d50..391c0f7 100644 --- a/lib/api/marianumcloud/talk/actions/talk_actions.dart +++ b/lib/api/marianumcloud/talk/actions/talk_actions.dart @@ -4,6 +4,7 @@ import 'package:http/http.dart' as http; import '../../../api_params.dart'; import '../../../api_response.dart'; +import '../room/get_room_response.dart'; import '../talk_api.dart'; /// Small POST/DELETE-only Talk endpoints that have no response payload. @@ -105,3 +106,21 @@ class DeleteRoomAvatar extends TalkApi { Map? headers, ) => http.delete(uri, headers: headers); } + +class SetNotificationLevel extends TalkApi { + final String chatToken; + final GetRoomResponseObjectParticipantNotificationLevel level; + + SetNotificationLevel(this.chatToken, this.level) + : super('v4/room/$chatToken/notify', null); + + @override + ApiResponse? assemble(String raw) => null; + + @override + Future request( + Uri uri, + ApiParams? body, + Map? headers, + ) => http.post(uri, headers: headers, body: {'level': '${level.apiValue}'}); +} diff --git a/lib/api/marianumcloud/talk/room/get_room_response.dart b/lib/api/marianumcloud/talk/room/get_room_response.dart index b720c65..5610efa 100644 --- a/lib/api/marianumcloud/talk/room/get_room_response.dart +++ b/lib/api/marianumcloud/talk/room/get_room_response.dart @@ -130,6 +130,12 @@ enum GetRoomResponseObjectParticipantNotificationLevel { neverNotify, } +extension NotificationLevelApiValue + on GetRoomResponseObjectParticipantNotificationLevel { + int get apiValue => + _$GetRoomResponseObjectParticipantNotificationLevelEnumMap[this]!; +} + enum GetRoomResponseObjectMessageActorType { @JsonValue('deleted_users') deletedUsers, diff --git a/lib/api/marianumcloud/talk/room/notification_level.dart b/lib/api/marianumcloud/talk/room/notification_level.dart new file mode 100644 index 0000000..39f1d6a --- /dev/null +++ b/lib/api/marianumcloud/talk/room/notification_level.dart @@ -0,0 +1,27 @@ +import 'get_room_response.dart'; + +typedef NotificationLevel = GetRoomResponseObjectParticipantNotificationLevel; + +/// The levels a user can pick in the UI; [NotificationLevel.defaultLevel] +/// is only ever reported by the server, never chosen. +const selectableNotificationLevels = [ + NotificationLevel.alwaysNotify, + NotificationLevel.notifyOnMention, + NotificationLevel.neverNotify, +]; + +extension RoomNotificationLevel on GetRoomResponseObject { + /// Resolves "default" the way Spreed does: 1:1 chats notify on every + /// message, group chats only on mentions (`default_group_notification`). + NotificationLevel get effectiveNotificationLevel { + if (notificationLevel != NotificationLevel.defaultLevel) { + return notificationLevel; + } + return type == GetRoomResponseObjectConversationType.oneToOne + ? NotificationLevel.alwaysNotify + : NotificationLevel.notifyOnMention; + } + + bool get isMuted => + effectiveNotificationLevel == NotificationLevel.neverNotify; +} diff --git a/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart b/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart index caaa3e8..d9aec94 100644 --- a/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart +++ b/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart @@ -3,6 +3,7 @@ import 'dart:developer'; import 'package:flutter_app_badge/flutter_app_badge.dart'; +import '../../../../../api/marianumcloud/talk/actions/talk_actions.dart'; import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; import '../../../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart'; @@ -126,6 +127,18 @@ class ChatListBloc }); } + Future setNotificationLevel( + String token, + GetRoomResponseObjectParticipantNotificationLevel level, + ) async { + await SetNotificationLevel(token, level).run(); + _mutateRoom(token, (r) { + if (r.notificationLevel == level) return false; + r.notificationLevel = level; + return true; + }); + } + /// Clears unread too — long-poll only feeds this in for an actively-open chat. void applyIncomingMessage(String token, GetChatResponseObject message) { _mutateRoom(token, (r) { diff --git a/lib/view/pages/talk/details/chat_info.dart b/lib/view/pages/talk/details/chat_info.dart index 58a2880..5f07346 100644 --- a/lib/view/pages/talk/details/chat_info.dart +++ b/lib/view/pages/talk/details/chat_info.dart @@ -6,6 +6,7 @@ import '../../../../api/marianumcloud/talk/get_participants/get_participants_cac import '../../../../api/marianumcloud/talk/get_participants/get_participants_response.dart'; import '../../../../api/marianumcloud/talk/get_shared_items/get_shared_items_overview_response.dart'; import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; +import '../../../../api/marianumcloud/talk/room/notification_level.dart'; import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import '../../../../widget/app_progress_indicator.dart'; import '../../../../widget/async_action_button.dart'; @@ -14,6 +15,7 @@ import '../../../../widget/confirm_dialog.dart'; import '../../../../widget/large_profile_picture_view.dart'; import '../../../../widget/user_avatar.dart'; import '../talk_navigator.dart'; +import '../widgets/notification_level_sheet.dart'; import 'participants_list_view.dart'; import 'shared_items_view.dart'; @@ -29,6 +31,7 @@ class _ChatInfoState extends State { GetParticipantsResponse? participants; GetSharedItemsOverviewResponse? _sharesOverview; late bool _isFavorite; + late NotificationLevel _notificationLevel; int _avatarVersion = 0; bool _avatarBusy = false; @@ -36,6 +39,7 @@ class _ChatInfoState extends State { void initState() { super.initState(); _isFavorite = widget.room.isFavorite; + _notificationLevel = widget.room.effectiveNotificationLevel; GetParticipantsCache( chatToken: widget.room.token, onUpdate: (GetParticipantsResponse data) { @@ -67,6 +71,16 @@ class _ChatInfoState extends State { _refreshList(); } + Future _editNotificationLevel() async { + final level = await showNotificationLevelSheet( + context, + widget.room, + current: _notificationLevel, + ); + if (level == null || !mounted) return; + setState(() => _notificationLevel = level); + } + Future _confirmLeave() async { final closed = await showDialog( context: context, @@ -243,6 +257,11 @@ class _ChatInfoState extends State { title: const Text('Zu Favoriten hinzufügen'), onPressed: _toggleFavorite, ), + NotificationLevelTile( + level: _notificationLevel, + trailing: const Icon(Icons.arrow_right), + onTap: _editNotificationLevel, + ), const Divider(), ListTile( leading: Icon( diff --git a/lib/view/pages/talk/widgets/chat_tile.dart b/lib/view/pages/talk/widgets/chat_tile.dart index 36184cc..a90b129 100644 --- a/lib/view/pages/talk/widgets/chat_tile.dart +++ b/lib/view/pages/talk/widgets/chat_tile.dart @@ -6,6 +6,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../api/marianumcloud/talk/actions/talk_actions.dart'; import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart'; import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; +import '../../../../api/marianumcloud/talk/room/notification_level.dart'; import '../../../../api/marianumcloud/talk/set_read_marker/set_read_marker.dart'; import '../../../../extensions/date_time.dart'; import '../../../../model/account_data.dart'; @@ -22,6 +23,7 @@ import '../../../../widget/details_bottom_sheet.dart'; import '../../../../widget/user_avatar.dart'; import '../data/talk_markdown.dart'; import '../talk_navigator.dart'; +import 'notification_level_sheet.dart'; class ChatTile extends StatefulWidget { final GetRoomResponseObject data; @@ -151,6 +153,15 @@ class _ChatTileState extends State { semanticLabel: A11yLabels.draft, ), ], + if (widget.data.isMuted) ...[ + const SizedBox(width: 5), + Icon( + NotificationLevel.neverNotify.icon, + size: 15, + color: Theme.of(context).colorScheme.onSurfaceVariant, + semanticLabel: A11yLabels.muted, + ), + ], ], ), subtitle: Text( @@ -238,6 +249,17 @@ class _ChatTileState extends State { if (mounted) _refreshList(); }, ), + NotificationLevelTile( + level: widget.data.effectiveNotificationLevel, + onTap: () { + Navigator.of(sheetCtx).pop(); + showNotificationLevelSheet( + context, + widget.data, + current: widget.data.effectiveNotificationLevel, + ); + }, + ), ListTile( leading: const Icon(Icons.delete_outline), title: const Text('Talk-Chat verlassen'), diff --git a/lib/view/pages/talk/widgets/notification_level_sheet.dart b/lib/view/pages/talk/widgets/notification_level_sheet.dart new file mode 100644 index 0000000..a704492 --- /dev/null +++ b/lib/view/pages/talk/widgets/notification_level_sheet.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; +import '../../../../api/marianumcloud/talk/room/notification_level.dart'; +import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; +import '../../../../widget/async_action_button.dart'; +import '../../../../widget/details_bottom_sheet.dart'; + +extension NotificationLevelPresentation on NotificationLevel { + // defaultLevel never reaches the UI (see effectiveNotificationLevel), but + // the switch has to stay exhaustive. + ({String label, String description, IconData icon}) get _presentation => + switch (this) { + NotificationLevel.alwaysNotify || NotificationLevel.defaultLevel => ( + label: 'Alle Nachrichten', + description: 'Bei jeder neuen Nachricht', + icon: Icons.notifications_outlined, + ), + NotificationLevel.notifyOnMention => ( + label: 'Nur Erwähnungen', + description: 'Nur wenn du erwähnt wirst', + icon: Icons.alternate_email, + ), + NotificationLevel.neverNotify => ( + label: 'Aus', + description: 'Keine Benachrichtigungen', + icon: Icons.notifications_off_outlined, + ), + }; + + String get label => _presentation.label; + String get description => _presentation.description; + IconData get icon => _presentation.icon; +} + +/// Lets the user pick the room's Talk notification level. Nextcloud applies +/// it server-side, so no push for the room is sent at all when muted. +/// Returns the new level, or null when nothing changed. +Future showNotificationLevelSheet( + BuildContext context, + GetRoomResponseObject room, { + required NotificationLevel current, +}) async { + final bloc = context.read(); + NotificationLevel? selected; + await showDetailsBottomSheet( + context, + header: ListTile( + leading: const Icon(Icons.notifications_outlined), + title: const Text('Benachrichtigungen'), + subtitle: Text(room.displayName, overflow: TextOverflow.ellipsis), + ), + children: (_) => [ + for (final level in selectableNotificationLevels) + AsyncListTile( + leading: Icon( + level == current + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + ), + title: Text(level.label), + subtitle: Text(level.description), + onPressed: () async { + if (level == current) return; + await bloc.setNotificationLevel(room.token, level); + selected = level; + }, + ), + ], + ); + return selected; +} + +class NotificationLevelTile extends StatelessWidget { + final NotificationLevel level; + final VoidCallback onTap; + final Widget? trailing; + + const NotificationLevelTile({ + required this.level, + required this.onTap, + this.trailing, + super.key, + }); + + @override + Widget build(BuildContext context) => ListTile( + leading: Icon(level.icon), + title: const Text('Benachrichtigungen'), + subtitle: Text(level.label), + trailing: trailing, + onTap: onTap, + ); +} diff --git a/lib/widget/a11y/a11y_labels.dart b/lib/widget/a11y/a11y_labels.dart index 806ce6f..e0af333 100644 --- a/lib/widget/a11y/a11y_labels.dart +++ b/lib/widget/a11y/a11y_labels.dart @@ -14,6 +14,7 @@ abstract final class A11yLabels { static const unread = 'Ungelesen'; static const favorite = 'Favorit'; static const draft = 'Entwurf'; + static const muted = 'Stummgeschaltet'; static const yourReaction = 'deine Reaktion'; // Bilder / Kontakte diff --git a/test/talk/notification_level_test.dart b/test/talk/notification_level_test.dart new file mode 100644 index 0000000..234ab63 --- /dev/null +++ b/test/talk/notification_level_test.dart @@ -0,0 +1,79 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/api/marianumcloud/talk/chat/get_chat_response.dart'; +import 'package:marianum_mobile/api/marianumcloud/talk/room/get_room_response.dart'; +import 'package:marianum_mobile/api/marianumcloud/talk/room/notification_level.dart'; + +typedef Level = GetRoomResponseObjectParticipantNotificationLevel; +typedef RoomType = GetRoomResponseObjectConversationType; + +GetRoomResponseObject _room(RoomType type, Level level) => + GetRoomResponseObject( + 1, + 't', + type, + 'n', + 'd', + '', + 3, + 0, + 0, + 0, + 0, + 's', + false, + false, + 0, + false, + false, + true, + 0, + false, + level, + 0, + false, + false, + 0, + 0, + GetChatResponseObject( + 1, + 't', + GetRoomResponseObjectMessageActorType.user, + 'a', + 'A', + 0, + '', + GetRoomResponseObjectMessageType.comment, + true, + 'r', + 'm', + null, + null, + null, + null, + false, + ), + null, + null, + null, + ); + +void main() { + test('Standard wird bei 1:1-Chats zu „Alle Nachrichten"', () { + final room = _room(RoomType.oneToOne, Level.defaultLevel); + expect(room.effectiveNotificationLevel, Level.alwaysNotify); + expect(room.isMuted, isFalse); + }); + + test('Standard wird bei Gruppen zu „Nur Erwähnungen"', () { + expect( + _room(RoomType.group, Level.defaultLevel).effectiveNotificationLevel, + Level.notifyOnMention, + ); + }); + + test('explizit gesetzter Level bleibt erhalten', () { + final room = _room(RoomType.oneToOne, Level.neverNotify); + expect(room.effectiveNotificationLevel, Level.neverNotify); + expect(room.isMuted, isTrue); + }); +} From ed8e52f47523ff7bd22b3f820747c9c6e1cb81ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Fri, 25 Sep 2026 22:27:10 +0200 Subject: [PATCH 06/18] timetable performance optimization --- lib/extensions/date_time.dart | 5 + .../bloc/foreign_timetable_bloc.dart | 18 +- .../timetable/bloc/timetable_bloc.dart | 20 +- .../timetable/bloc/timetable_state.dart | 18 ++ .../modules/timetable/bloc/week_cache.dart | 45 +++++ .../data/timetable_appointment_factory.dart | 10 +- .../timetable/widgets/appointment_tile.dart | 38 ++-- .../widgets/calendar/outside_chips.dart | 10 +- .../timetable/widgets/calendar/week_grid.dart | 50 +++-- .../widgets/custom_workweek_calendar.dart | 174 +++++++++++++----- .../widgets/timetable_calendar_view.dart | 57 ++++-- test/extensions/date_time_test.dart | 9 + test/state/week_cache_test.dart | 74 ++++++++ 13 files changed, 391 insertions(+), 137 deletions(-) create mode 100644 lib/state/app/modules/timetable/bloc/week_cache.dart create mode 100644 test/state/week_cache_test.dart diff --git a/lib/extensions/date_time.dart b/lib/extensions/date_time.dart index 485b275..43fe685 100644 --- a/lib/extensions/date_time.dart +++ b/lib/extensions/date_time.dart @@ -36,6 +36,11 @@ extension CalendarDayArithmetic on DateTime { ); DateTime subtractDays(int days) => addDays(-days); + + DateTime get dateOnly => DateTime(year, month, day); + + /// Monday 00:00 of this date's week. + DateTime get mondayOfWeek => subtractDays(weekday - 1).dateOnly; } /// Formatting helpers backed by Jiffy. Centralises the patterns that previously diff --git a/lib/state/app/modules/foreign_timetable/bloc/foreign_timetable_bloc.dart b/lib/state/app/modules/foreign_timetable/bloc/foreign_timetable_bloc.dart index 12b7c78..4a58546 100644 --- a/lib/state/app/modules/foreign_timetable/bloc/foreign_timetable_bloc.dart +++ b/lib/state/app/modules/foreign_timetable/bloc/foreign_timetable_bloc.dart @@ -45,7 +45,7 @@ class ForeignTimetableBloc TimetableState fromNothing() { final reference = DateTime.now().addDays(2); return TimetableState( - startDate: _startOfWeek(reference), + startDate: reference.mondayOfWeek, endDate: _endOfWeek(reference), ); } @@ -101,7 +101,7 @@ class ForeignTimetableBloc void resetWeek() { final reference = DateTime.now().addDays(2); - changeWeek(_startOfWeek(reference), _endOfWeek(reference)); + changeWeek(reference.mondayOfWeek, _endOfWeek(reference)); } void refresh() => fetch(); @@ -180,19 +180,7 @@ class ForeignTimetableBloc } void _writeWeekToCache(DateTime weekStart, TimetableGetWeekResponse week) { - final key = weekStart.weekKey(); - add( - Emit((s) { - final updated = Map.of(s.weekCache); - updated[key] = week; - return s.copyWith(weekCache: updated, dataVersion: s.dataVersion + 1); - }), - ); - } - - static DateTime _startOfWeek(DateTime reference) { - final monday = reference.subtractDays(reference.weekday - 1); - return DateTime(monday.year, monday.month, monday.day); + add(Emit((s) => s.withFetchedWeek(weekStart, week))); } static DateTime _endOfWeek(DateTime reference) { diff --git a/lib/state/app/modules/timetable/bloc/timetable_bloc.dart b/lib/state/app/modules/timetable/bloc/timetable_bloc.dart index e797e37..60380a2 100644 --- a/lib/state/app/modules/timetable/bloc/timetable_bloc.dart +++ b/lib/state/app/modules/timetable/bloc/timetable_bloc.dart @@ -36,7 +36,7 @@ class TimetableBloc TimetableState fromNothing() { final reference = DateTime.now().addDays(2); return TimetableState( - startDate: _startOfWeek(reference), + startDate: reference.mondayOfWeek, endDate: _endOfWeek(reference), ); } @@ -49,7 +49,7 @@ class TimetableBloc final stored = TimetableState.fromJson(json); final reference = DateTime.now().addDays(2); return stored.copyWith( - startDate: _startOfWeek(reference), + startDate: reference.mondayOfWeek, endDate: _endOfWeek(reference), accessibleStartDate: null, accessibleEndDate: null, @@ -99,7 +99,7 @@ class TimetableBloc void resetWeek() { final reference = DateTime.now().addDays(2); - changeWeek(_startOfWeek(reference), _endOfWeek(reference)); + changeWeek(reference.mondayOfWeek, _endOfWeek(reference)); } void refresh() => fetch(); @@ -245,19 +245,7 @@ class TimetableBloc } void _writeWeekToCache(DateTime weekStart, TimetableGetWeekResponse week) { - final key = weekStart.weekKey(); - add( - Emit((s) { - final updated = Map.of(s.weekCache); - updated[key] = week; - return s.copyWith(weekCache: updated, dataVersion: s.dataVersion + 1); - }), - ); - } - - static DateTime _startOfWeek(DateTime reference) { - final monday = reference.subtractDays(reference.weekday - 1); - return DateTime(monday.year, monday.month, monday.day); + add(Emit((s) => s.withFetchedWeek(weekStart, week))); } static DateTime _endOfWeek(DateTime reference) { diff --git a/lib/state/app/modules/timetable/bloc/timetable_state.dart b/lib/state/app/modules/timetable/bloc/timetable_state.dart index df00dd7..12117a5 100644 --- a/lib/state/app/modules/timetable/bloc/timetable_state.dart +++ b/lib/state/app/modules/timetable/bloc/timetable_state.dart @@ -7,6 +7,7 @@ import '../../../../../api/marianumconnect/queries/timetable_get_subjects/timeta import '../../../../../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart'; import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; import '../../../../../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart'; +import 'week_cache.dart'; part 'timetable_state.freezed.dart'; part 'timetable_state.g.dart'; @@ -40,6 +41,23 @@ abstract class TimetableState with _$TimetableState { Iterable getAllKnownLessons() => weekCache.values.expand((response) => response.entries); + /// This state with [week] cached, or `this` when its content is unchanged + /// (so the emit is dropped as equal). + TimetableState withFetchedWeek( + DateTime weekStart, + TimetableGetWeekResponse week, + ) { + final updated = mergeWeekIntoCache( + weekCache, + weekStart, + week, + viewedWeekStart: startDate, + now: DateTime.now(), + ); + if (updated == null) return this; + return copyWith(weekCache: updated, dataVersion: dataVersion + 1); + } + bool get hasReferenceData => rooms != null && subjects != null && diff --git a/lib/state/app/modules/timetable/bloc/week_cache.dart b/lib/state/app/modules/timetable/bloc/week_cache.dart new file mode 100644 index 0000000..5281912 --- /dev/null +++ b/lib/state/app/modules/timetable/bloc/week_cache.dart @@ -0,0 +1,45 @@ +import 'package:collection/collection.dart'; + +import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; +import '../../../../../extensions/date_time.dart'; + +/// Weeks kept around the viewed week and around today's week. Everything +/// further away is dropped: every state emit re-serializes the whole cache for +/// HydratedBloc and the calendar rebuilds its appointments from all of it, so +/// an unbounded cache makes week swipes slower the longer the app is used. +const int kWeekCacheRadius = 4; + +/// Returns the cache with [week] stored under [weekStart], pruned to the +/// weeks near [viewedWeekStart] or [now]. Returns null when the stored week +/// already has identical content, so callers can skip the emit entirely. +Map? mergeWeekIntoCache( + Map cache, + DateTime weekStart, + TimetableGetWeekResponse week, { + required DateTime viewedWeekStart, + required DateTime now, +}) { + final key = weekStart.weekKey(); + final existing = cache[key]; + if (existing != null && + const DeepCollectionEquality().equals(existing.toJson(), week.toJson())) { + return null; + } + + final viewedMonday = viewedWeekStart.mondayOfWeek; + final todayMonday = now.mondayOfWeek; + bool isNear(DateTime monday, DateTime anchor) => + monday.difference(anchor).inDays.abs() <= kWeekCacheRadius * 7 + 1; + + final updated = {}; + for (final entry in cache.entries) { + final monday = DateTime.tryParse(entry.key); + if (monday == null || + isNear(monday, viewedMonday) || + isNear(monday, todayMonday)) { + updated[entry.key] = entry.value; + } + } + updated[key] = week; + return updated; +} diff --git a/lib/view/pages/timetable/data/timetable_appointment_factory.dart b/lib/view/pages/timetable/data/timetable_appointment_factory.dart index c7ea181..0f9fa69 100644 --- a/lib/view/pages/timetable/data/timetable_appointment_factory.dart +++ b/lib/view/pages/timetable/data/timetable_appointment_factory.dart @@ -213,10 +213,12 @@ class TimetableAppointmentFactory { return LessonColor.forStatus(status); } - McSubject? _findSubject(String? subjectShort) { - if (subjectShort == null) return null; - return subjects.where((s) => s.shortName == subjectShort).firstOrNull; - } + late final Map _subjectsByShort = { + for (final s in subjects.reversed) s.shortName: s, + }; + + McSubject? _findSubject(String? subjectShort) => + subjectShort == null ? null : _subjectsByShort[subjectShort]; String _subjectName(String? subjectShort, McTimetableEntry lesson) { if (subjectShort != null) { diff --git a/lib/view/pages/timetable/widgets/appointment_tile.dart b/lib/view/pages/timetable/widgets/appointment_tile.dart index 0d8c1cf..a5ef8c8 100644 --- a/lib/view/pages/timetable/widgets/appointment_tile.dart +++ b/lib/view/pages/timetable/widgets/appointment_tile.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart'; @@ -11,16 +12,19 @@ class AppointmentTile extends StatelessWidget { final Appointment appointment; final bool crossedOut; + /// Ticking clock; only the tile background listens, so a lesson fades to + /// the "past" look without rebuilding the text layout. + final ValueListenable now; + const AppointmentTile({ super.key, required this.appointment, + required this.now, this.crossedOut = false, }); @override Widget build(BuildContext context) { - final isPast = appointment.endTime.isBefore(DateTime.now()); - final color = appointment.color.withAlpha(isPast ? 160 : 255); final isCustom = appointment.id is CustomAppointment; final description = appointment.location ?? ''; @@ -29,13 +33,19 @@ class AppointmentTile extends StatelessWidget { child: Stack( children: [ Positioned.fill( - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), - alignment: Alignment.topLeft, - decoration: BoxDecoration( - shape: BoxShape.rectangle, - borderRadius: _radius, - color: color, + child: ValueListenableBuilder( + valueListenable: now, + builder: (context, now, child) => Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + alignment: Alignment.topLeft, + decoration: BoxDecoration( + shape: BoxShape.rectangle, + borderRadius: _radius, + color: appointment.color.withAlpha( + appointment.endTime.isBefore(now) ? 160 : 255, + ), + ), + child: child, ), child: _TileContent( title: appointment.subject, @@ -100,8 +110,10 @@ class _TileContent extends StatelessWidget { // entirely; the coloured rectangle is enough. if (available < titleLineHeight) return const SizedBox.shrink(); - final remaining = - (available - titleLineHeight).clamp(0.0, double.infinity); + final remaining = (available - titleLineHeight).clamp( + 0.0, + double.infinity, + ); final bodyLineCapacity = (remaining / bodyLineHeight).floor(); if (isCustom) { @@ -187,7 +199,9 @@ class _AdaptiveTitle extends StatelessWidget { maxLines: 1, textScaler: textScaler, )..layout(); - if (probe.width > constraints.maxWidth) { + final overflows = probe.width > constraints.maxWidth; + probe.dispose(); + if (overflows) { return Text( text, style: baseStyle.copyWith(fontSize: minFontSize), diff --git a/lib/view/pages/timetable/widgets/calendar/outside_chips.dart b/lib/view/pages/timetable/widgets/calendar/outside_chips.dart index 35ba38a..259b1e2 100644 --- a/lib/view/pages/timetable/widgets/calendar/outside_chips.dart +++ b/lib/view/pages/timetable/widgets/calendar/outside_chips.dart @@ -1,16 +1,14 @@ part of '../custom_workweek_calendar.dart'; class _OutsideHoursStrip extends StatelessWidget { - final DateTime weekStart; - final List appointments; + final List> outside; final double rulerWidth; final void Function(Appointment) onAppointmentTap; final bool Function(Appointment) isCrossedOut; const _OutsideHoursStrip({ super.key, - required this.weekStart, - required this.appointments, + required this.outside, required this.rulerWidth, required this.onAppointmentTap, required this.isCrossedOut, @@ -18,10 +16,6 @@ class _OutsideHoursStrip extends StatelessWidget { @override Widget build(BuildContext context) { - final outside = partitionAppointmentsForWeek( - appointments, - weekStart, - ).outside; if (outside.every((day) => day.isEmpty)) return const SizedBox.shrink(); final theme = Theme.of(context); diff --git a/lib/view/pages/timetable/widgets/calendar/week_grid.dart b/lib/view/pages/timetable/widgets/calendar/week_grid.dart index f46fa61..e2c6e0e 100644 --- a/lib/view/pages/timetable/widgets/calendar/week_grid.dart +++ b/lib/view/pages/timetable/widgets/calendar/week_grid.dart @@ -1,10 +1,31 @@ part of '../custom_workweek_calendar.dart'; +/// Per-week derived data, computed once per appointment/region set instead of +/// on every rebuild of a week page. +class _WeekData { + final List> inside; + final List> outside; + final List> regions; + + _WeekData._(this.inside, this.outside, this.regions); + + factory _WeekData.compute( + DateTime weekStart, + List appointments, + List timeRegions, + ) { + final partitioned = partitionAppointmentsForWeek(appointments, weekStart); + return _WeekData._(partitioned.inside, partitioned.outside, [ + for (var d = 0; d < 5; d++) + expandRegionsForDay(timeRegions, weekStart.addDays(d)), + ]); + } +} + class _WeekGrid extends StatelessWidget { final DateTime weekStart; final LessonPeriodSchedule schedule; - final List appointments; - final List timeRegions; + final _WeekData data; final void Function(Appointment) onAppointmentTap; final bool Function(Appointment) isCrossedOut; final void Function(DateTime start, DateTime end)? onCreateEvent; @@ -16,8 +37,7 @@ class _WeekGrid extends StatelessWidget { const _WeekGrid({ required this.weekStart, required this.schedule, - required this.appointments, - required this.timeRegions, + required this.data, required this.onAppointmentTap, required this.isCrossedOut, required this.onCreateEvent, @@ -29,8 +49,6 @@ class _WeekGrid extends StatelessWidget { @override Widget build(BuildContext context) { - final partitioned = partitionAppointmentsForWeek(appointments, weekStart); - return Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -48,8 +66,8 @@ class _WeekGrid extends StatelessWidget { child: _DayColumn( date: weekStart.addDays(d), schedule: schedule, - appointments: partitioned.inside[d], - timeRegions: timeRegions, + appointments: data.inside[d], + regions: data.regions[d], layout: layout, today: today, nowNotifier: nowNotifier, @@ -195,7 +213,7 @@ class _DayColumn extends StatelessWidget { final DateTime date; final LessonPeriodSchedule schedule; final List appointments; - final List timeRegions; + final List regions; final PeriodLayout layout; final DateTime today; final ValueListenable nowNotifier; @@ -207,7 +225,7 @@ class _DayColumn extends StatelessWidget { required this.date, required this.schedule, required this.appointments, - required this.timeRegions, + required this.regions, required this.layout, required this.today, required this.nowNotifier, @@ -280,12 +298,10 @@ class _DayColumn extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); - final dayAppointments = appointments; - final dayRegions = expandRegionsForDay(timeRegions, date); final isToday = date.isSameDay(today); - final isTablet = MediaQuery.of(context).size.shortestSide >= 600; - final laidOut = assignLanes(dayAppointments, maxLanes: isTablet ? 3 : 2); + final isTablet = MediaQuery.sizeOf(context).shortestSide >= 600; + final laidOut = assignLanes(appointments, maxLanes: isTablet ? 3 : 2); final dayName = DateFormat( 'EEEE', @@ -304,8 +320,7 @@ class _DayColumn extends StatelessWidget { sortKey: OrdinalSortKey(date.weekday.toDouble()), child: GestureDetector( behavior: HitTestBehavior.translucent, - onLongPressStart: (details) => - _handleLongPress(details, dayAppointments), + onLongPressStart: (details) => _handleLongPress(details, appointments), child: DecoratedBox( decoration: BoxDecoration( color: isToday ? theme.colorScheme.primary.withAlpha(14) : null, @@ -342,7 +357,7 @@ class _DayColumn extends StatelessWidget { color: theme.dividerColor.withAlpha(60), ), ), - for (final region in dayRegions) + for (final region in regions) Positioned( top: layout.yOfDateTime(region.start), height: @@ -379,6 +394,7 @@ class _DayColumn extends StatelessWidget { onTap: () => onAppointmentTap(appointment), child: AppointmentTile( appointment: appointment, + now: nowNotifier, crossedOut: isCrossedOut(appointment), ), ), diff --git a/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart b/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart index 690a749..6f4f108 100644 --- a/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart +++ b/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart @@ -61,29 +61,41 @@ class CustomWorkWeekCalendarState extends State { static const double _rulerWidth = 36; late PageController _pageController; - late int _currentWeekIndex; + // Drives header + all-day strip. Kept out of setState so a page change + // mid-swipe doesn't rebuild the PageView and every tile in it. + late final ValueNotifier _visibleWeekIndex; + late int _reportedWeekIndex; late DateTime _firstMonday; late int _totalWeeks; late Timer _ticker; late ValueNotifier _nowNotifier; - DateTime _today = _dateOnly(DateTime.now()); + DateTime _today = DateTime.now().dateOnly; + + final Map _weekData = {}; + // Returning the identical widget instance lets Flutter skip rebuilding a + // week page when the host rebuilds for unrelated reasons (e.g. the bloc + // recording the new week after a swipe). + final Map _gridCache = {}; + Object? _cacheKey; @override void initState() { super.initState(); - _firstMonday = _mondayOf(widget.minDate); - final lastMonday = _mondayOf(widget.maxDate); + _firstMonday = widget.minDate.mondayOfWeek; + final lastMonday = widget.maxDate.mondayOfWeek; _totalWeeks = lastMonday.difference(_firstMonday).inDays ~/ 7 + 1; - _currentWeekIndex = - _mondayOf(widget.initialDate).difference(_firstMonday).inDays ~/ 7; - _pageController = PageController(initialPage: _currentWeekIndex); + final initialIndex = + widget.initialDate.mondayOfWeek.difference(_firstMonday).inDays ~/ 7; + _visibleWeekIndex = ValueNotifier(initialIndex); + _reportedWeekIndex = initialIndex; + _pageController = PageController(initialPage: initialIndex); _nowNotifier = ValueNotifier(DateTime.now()); _ticker = Timer.periodic(const Duration(seconds: 30), (_) { if (!mounted) return; final now = DateTime.now(); _nowNotifier.value = now; - final newToday = _dateOnly(now); + final newToday = now.dateOnly; if (newToday != _today) setState(() => _today = newToday); }); } @@ -99,11 +111,11 @@ class CustomWorkWeekCalendarState extends State { // the initial mount with the conservative fallback). Recompute the // range and snap the controller to the page that still represents the // currently visible week so the user doesn't get yanked around. - final newFirstMonday = _mondayOf(widget.minDate); - final newLastMonday = _mondayOf(widget.maxDate); + final newFirstMonday = widget.minDate.mondayOfWeek; + final newLastMonday = widget.maxDate.mondayOfWeek; final newTotalWeeks = newLastMonday.difference(newFirstMonday).inDays ~/ 7 + 1; - final visibleWeekStart = _firstMonday.addDays(_currentWeekIndex * 7); + final visibleWeekStart = _firstMonday.addDays(_visibleWeekIndex.value * 7); final newIndex = visibleWeekStart.difference(newFirstMonday).inDays ~/ 7; final clampedIndex = newIndex.clamp(0, newTotalWeeks - 1); final oldController = _pageController; @@ -112,8 +124,9 @@ class CustomWorkWeekCalendarState extends State { setState(() { _firstMonday = newFirstMonday; _totalWeeks = newTotalWeeks; - _currentWeekIndex = clampedIndex; }); + _visibleWeekIndex.value = clampedIndex; + _reportedWeekIndex = clampedIndex; } @override @@ -121,13 +134,12 @@ class CustomWorkWeekCalendarState extends State { _pageController.dispose(); _ticker.cancel(); _nowNotifier.dispose(); + _visibleWeekIndex.dispose(); super.dispose(); } - static DateTime _dateOnly(DateTime d) => DateTime(d.year, d.month, d.day); - void jumpToDate(DateTime date) { - final target = _mondayOf(date).difference(_firstMonday).inDays ~/ 7; + final target = date.mondayOfWeek.difference(_firstMonday).inDays ~/ 7; if (target < 0 || target >= _totalWeeks) return; _pageController.animateToPage( target, @@ -136,16 +148,79 @@ class CustomWorkWeekCalendarState extends State { ); } - static DateTime _mondayOf(DateTime d) { - final monday = d.subtractDays(d.weekday - 1); - return DateTime(monday.year, monday.month, monday.day); + /// Drops both per-week caches when any input they were built from changed. + void _syncCaches() { + final key = ( + widget.appointments, + widget.timeRegions, + widget.schedule, + widget.onCreateEvent == null, + _today, + _firstMonday, + ); + if (key == _cacheKey) return; + _cacheKey = key; + _weekData.clear(); + _gridCache.clear(); } - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final visibleWeekStart = _firstMonday.addDays(_currentWeekIndex * 7); + _WeekData _weekDataFor(int weekIndex) { + return _weekData.putIfAbsent( + weekIndex, + () => _WeekData.compute( + _firstMonday.addDays(weekIndex * 7), + widget.appointments, + widget.timeRegions, + ), + ); + } + /// Tells the host about the new week only once the swipe has settled, so + /// the resulting bloc emit + rebuild never lands inside the page animation. + bool _onPageScroll(ScrollNotification notification) { + if (notification is! ScrollEndNotification || notification.depth != 0) { + return false; + } + final index = _visibleWeekIndex.value; + if (index == _reportedWeekIndex) return false; + _reportedWeekIndex = index; + final weekStart = _firstMonday.addDays(index * 7); + widget.onWeekChanged(weekStart, weekStart.addDays(4)); + return false; + } + + void _onAppointmentTap(Appointment appointment) => + widget.onAppointmentTap(appointment); + + bool _isCrossedOut(Appointment appointment) => + widget.isCrossedOut(appointment); + + void _onCreateEvent(DateTime start, DateTime end) => + widget.onCreateEvent?.call(start, end); + + _WeekGrid _weekGrid(int weekIndex, PeriodLayout layout) { + final cached = _gridCache[weekIndex]; + if (cached != null && cached.layout.lessonHeight == layout.lessonHeight) { + return cached; + } + // Only the pages next to the visible one are ever alive. + _gridCache.removeWhere((i, _) => (i - weekIndex).abs() > 2); + return _gridCache[weekIndex] = _WeekGrid( + weekStart: _firstMonday.addDays(weekIndex * 7), + schedule: widget.schedule, + data: _weekDataFor(weekIndex), + onAppointmentTap: _onAppointmentTap, + isCrossedOut: _isCrossedOut, + onCreateEvent: widget.onCreateEvent == null ? null : _onCreateEvent, + today: _today, + nowNotifier: _nowNotifier, + rulerWidth: _rulerWidth, + layout: layout, + ); + } + + Widget _buildWeekHeader(int weekIndex) { + final visibleWeekStart = _firstMonday.addDays(weekIndex * 7); return Column( children: [ SizedBox( @@ -190,15 +265,29 @@ class CustomWorkWeekCalendarState extends State { FadeTransition(opacity: animation, child: child), child: _OutsideHoursStrip( key: ValueKey(visibleWeekStart), - weekStart: visibleWeekStart, - appointments: widget.appointments, + outside: _weekDataFor(weekIndex).outside, rulerWidth: _rulerWidth, - onAppointmentTap: widget.onAppointmentTap, - isCrossedOut: widget.isCrossedOut, + onAppointmentTap: _onAppointmentTap, + isCrossedOut: _isCrossedOut, ), ), ), ), + ], + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + _syncCaches(); + + return Column( + children: [ + ValueListenableBuilder( + valueListenable: _visibleWeekIndex, + builder: (_, weekIndex, _) => _buildWeekHeader(weekIndex), + ), Container(height: 0.5, color: theme.dividerColor.withAlpha(110)), Expanded( child: LayoutBuilder( @@ -225,30 +314,15 @@ class CustomWorkWeekCalendarState extends State { physics: const AlwaysScrollableScrollPhysics(), child: SizedBox( height: gridHeight, - child: PageView.builder( - controller: _pageController, - itemCount: _totalWeeks, - onPageChanged: (index) { - setState(() => _currentWeekIndex = index); - final weekStart = _firstMonday.addDays(index * 7); - widget.onWeekChanged(weekStart, weekStart.addDays(4)); - }, - itemBuilder: (_, weekIndex) { - final weekStart = _firstMonday.addDays(weekIndex * 7); - return _WeekGrid( - weekStart: weekStart, - schedule: widget.schedule, - appointments: widget.appointments, - timeRegions: widget.timeRegions, - onAppointmentTap: widget.onAppointmentTap, - isCrossedOut: widget.isCrossedOut, - onCreateEvent: widget.onCreateEvent, - today: _today, - nowNotifier: _nowNotifier, - rulerWidth: _rulerWidth, - layout: layout, - ); - }, + child: NotificationListener( + onNotification: _onPageScroll, + child: PageView.builder( + controller: _pageController, + itemCount: _totalWeeks, + onPageChanged: (index) => _visibleWeekIndex.value = index, + itemBuilder: (_, weekIndex) => + _weekGrid(weekIndex, layout), + ), ), ), ); diff --git a/lib/view/pages/timetable/widgets/timetable_calendar_view.dart b/lib/view/pages/timetable/widgets/timetable_calendar_view.dart index e7287a8..5224f1f 100644 --- a/lib/view/pages/timetable/widgets/timetable_calendar_view.dart +++ b/lib/view/pages/timetable/widgets/timetable_calendar_view.dart @@ -55,6 +55,13 @@ class TimetableCalendarViewState extends State { // the same identity checks the cache always used. (int, TimetableSettings, List, bool)? _cacheKey; + // Stable identities let the calendar reuse its per-week pages across + // rebuilds; rebuilding these every frame would invalidate that cache. + LessonPeriodSchedule? _schedule; + Object? _scheduleKey; + List? _regions; + Object? _regionsKey; + DateTime _initialDisplayDate() => DateTime.now().addDays(2); /// Snaps the calendar back to the current week. Exposed so host pages can @@ -90,7 +97,37 @@ class TimetableCalendarViewState extends State { ).build(); } - bool _isCrossedOut(Appointment appointment) { + LessonPeriodSchedule _scheduleFor(TimetableState state) { + if (_schedule != null && identical(_scheduleKey, state.timegrid)) { + return _schedule!; + } + _scheduleKey = state.timegrid; + return _schedule = LessonPeriodSchedule.fromState(state); + } + + List _regionsFor( + TimetableState state, + LessonPeriodSchedule schedule, + ) { + final theme = Theme.of(context); + final key = ( + state.schoolHolidays, + schedule, + theme.colorScheme, + theme.disabledColor, + DateTime.now().dateOnly, + ); + if (_regions != null && key == _regionsKey) return _regions!; + _regionsKey = key; + return _regions = SpecialRegionsBuilder( + holidays: state.schoolHolidays!, + schedule: schedule, + colorScheme: theme.colorScheme, + disabledColor: theme.disabledColor, + ).build(); + } + + static bool _isCrossedOut(Appointment appointment) { final id = appointment.id; if (id is LessonAppointment) return id.entry.status == 'CANCELLED'; return false; @@ -104,14 +141,9 @@ class TimetableCalendarViewState extends State { // transient null can never crash the build. if (state.schoolHolidays == null) return const SizedBox.shrink(); - final schedule = LessonPeriodSchedule.fromState(state); + final schedule = _scheduleFor(state); final appointments = _appointments(state); - final regions = SpecialRegionsBuilder( - holidays: state.schoolHolidays!, - schedule: schedule, - colorScheme: Theme.of(context).colorScheme, - disabledColor: Theme.of(context).disabledColor, - ).build(); + final regions = _regionsFor(state, schedule); final capabilities = context.watch(); final (minDate, maxDate) = _scrollBounds( @@ -171,8 +203,8 @@ class TimetableCalendarViewState extends State { ? state.accessibleEndDate! : baseMax) : baseMax; - final today = _startOfDay(DateTime.now()); - final todayMonday = _mondayOf(today); + final today = DateTime.now().dateOnly; + final todayMonday = today.mondayOfWeek; final currentWeekEnd = todayMonday.addDays(DateTime.daysPerWeek - 1); final capMin = pastDays == null ? null @@ -202,11 +234,6 @@ class TimetableCalendarViewState extends State { return (mondayMin, finalMax); } - static DateTime _mondayOf(DateTime d) => - _startOfDay(d.subtractDays(d.weekday - 1)); - - static DateTime _startOfDay(DateTime d) => DateTime(d.year, d.month, d.day); - static DateTime _earlier(DateTime a, DateTime b) => a.isBefore(b) ? a : b; static DateTime _later(DateTime a, DateTime b) => a.isAfter(b) ? a : b; diff --git a/test/extensions/date_time_test.dart b/test/extensions/date_time_test.dart index c1eb7be..cc1ac34 100644 --- a/test/extensions/date_time_test.dart +++ b/test/extensions/date_time_test.dart @@ -86,6 +86,15 @@ void main() { final d = DateTime(2026, 5, 18, 13, 45); expect(d.subtractDays(5), d.addDays(-5)); }); + + test('dateOnly drops the time of day', () { + expect(DateTime(2026, 9, 24, 13, 45).dateOnly, DateTime(2026, 9, 24)); + }); + + test('mondayOfWeek returns Monday midnight for any weekday', () { + expect(DateTime(2026, 9, 21).mondayOfWeek, DateTime(2026, 9, 21)); + expect(DateTime(2026, 9, 27, 23, 59).mondayOfWeek, DateTime(2026, 9, 21)); + }); }); group('DateTimeFormatting', () { diff --git a/test/state/week_cache_test.dart b/test/state/week_cache_test.dart new file mode 100644 index 0000000..5429a42 --- /dev/null +++ b/test/state/week_cache_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; +import 'package:marianum_mobile/extensions/date_time.dart'; +import 'package:marianum_mobile/state/app/modules/timetable/bloc/week_cache.dart'; + +TimetableGetWeekResponse _week(DateTime monday, {String room = 'A101'}) => + TimetableGetWeekResponse( + from: monday, + until: monday.addDays(4), + entries: [ + McTimetableEntry( + id: 1, + date: monday, + startTime: DateTime(1970, 1, 1, 8), + endTime: DateTime(1970, 1, 1, 9), + subjects: const ['ma'], + teachers: const [], + rooms: [room], + classNames: const [], + lessonType: 'LESSON', + status: 'REGULAR', + substitutionText: null, + lessonText: null, + infoText: null, + ), + ], + ); + +void main() { + final monday = DateTime(2026, 9, 21); + + test('returns null when the stored week has identical content', () { + final cache = {monday.weekKey(): _week(monday)}; + final result = mergeWeekIntoCache( + cache, + monday, + _week(monday), + viewedWeekStart: monday, + now: monday, + ); + expect(result, isNull); + }); + + test('stores changed content', () { + final cache = {monday.weekKey(): _week(monday)}; + final changed = _week(monday, room: 'B202'); + final result = mergeWeekIntoCache( + cache, + monday, + changed, + viewedWeekStart: monday, + now: monday, + ); + expect(result![monday.weekKey()], same(changed)); + }); + + test('drops weeks far from both the viewed week and today', () { + final far = monday.addDays(7 * (kWeekCacheRadius + 3)); + final nearToday = monday.addDays(-7 * kWeekCacheRadius); + final viewed = monday.addDays(7 * (kWeekCacheRadius * 3)); + final cache = { + far.weekKey(): _week(far), + nearToday.weekKey(): _week(nearToday), + }; + final result = mergeWeekIntoCache( + cache, + viewed, + _week(viewed), + viewedWeekStart: viewed, + now: monday.addDays(2), + ); + expect(result!.keys, {nearToday.weekKey(), viewed.weekKey()}); + }); +} From 56a497985b5fe6d9d13175b2561e40ce3ecef581 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Fri, 25 Sep 2026 23:58:08 +0200 Subject: [PATCH 07/18] improved performance and battery usage on older devices --- lib/api/cache_store.dart | 185 ++++++++++++++++++ .../talk/chat/get_chat_cache.dart | 2 +- .../talk/chat/get_chat_history.dart | 10 +- lib/api/marianumcloud/talk/room/get_room.dart | 12 +- lib/api/marianumcloud/talk/talk_api.dart | 6 +- .../queries/list_files/cacheable_file.dart | 13 ++ .../queries/list_files/list_files_cache.dart | 14 +- .../custom_events_migration.dart | 24 +-- .../timetable_get_week_response.dart | 11 +- lib/api/request_cache.dart | 84 ++++---- lib/app.dart | 71 +++++-- lib/extensions/text.dart | 21 +- lib/main.dart | 71 ++++--- lib/model/account_data.dart | 27 ++- lib/model/data_cleaner.dart | 22 +-- lib/model/session_wipe.dart | 4 +- lib/notification/notification_controller.dart | 11 +- lib/push/push_decryptor.dart | 20 +- lib/push/push_message_handler.dart | 8 +- .../bloc/loadable_state_bloc.dart | 18 +- .../bloc/loadable_state_event.dart | 7 +- .../view/loadable_state_consumer.dart | 2 +- .../view/loadable_state_error_bar.dart | 10 +- .../view/loadable_state_primary_loading.dart | 74 ++++--- .../loadable_hydrated_bloc.dart | 75 +++++-- .../app/modules/chat/bloc/chat_bloc.dart | 6 + .../chat_list/bloc/chat_list_bloc.dart | 46 ++++- .../bloc/foreign_timetable_bloc.dart | 9 +- .../marianum_dates_get_events.dart | 7 + .../modules/settings/bloc/settings_cubit.dart | 37 +++- .../modules/ticker/bloc/ticker_page_bloc.dart | 35 +++- .../timetable/bloc/timetable_bloc.dart | 28 +-- .../timetable/bloc/timetable_state.dart | 40 ++++ .../modules/timetable/bloc/week_cache.dart | 10 +- lib/utils/json_equality.dart | 7 + lib/utils/recurrence_occurrences.dart | 75 +++++++ lib/utils/screen_bound_image.dart | 25 +++ lib/utils/session_single_flight.dart | 21 ++ lib/view/pages/files/data/sort_options.dart | 2 +- lib/view/pages/files/files.dart | 33 +++- lib/view/pages/files/files_upload_dialog.dart | 18 +- .../files/search/files_search_controller.dart | 12 +- .../files/search/files_search_results.dart | 67 ++++--- .../files/search/local_cache_search.dart | 84 ++++---- .../element_picker_page.dart | 51 +++-- .../chat_background_settings_page.dart | 7 +- .../settings/sections/dev_tools_section.dart | 12 +- lib/view/pages/talk/chat_list.dart | 45 +++-- lib/view/pages/talk/chat_view.dart | 122 +++++++++--- lib/view/pages/talk/widgets/chat_bubble.dart | 50 +++-- .../talk/widgets/chat_bubble_reactions.dart | 2 +- .../pages/talk/widgets/chat_textfield.dart | 35 ++-- lib/view/pages/talk/widgets/chat_tile.dart | 20 +- .../pages/timetable/data/calendar_logic.dart | 12 +- lib/view/pages/timetable/timetable.dart | 11 +- .../widgets/timetable_calendar_view.dart | 24 ++- lib/widget/chat_background.dart | 24 ++- lib/widget/debug/cache_view.dart | 38 +--- lib/widget/file_viewer.dart | 95 +++++---- lib/widget/prosemirror/pm_image_view.dart | 58 +++--- lib/widget/prosemirror/pm_json_view.dart | 34 ++-- lib/widget/share_position_origin.dart | 4 +- lib/widget/user_avatar.dart | 4 + lib/widget_data/widget_data_mapper.dart | 13 +- lib/widget_data/widget_publisher.dart | 73 ++++++- lib/widget_data/widget_sync.dart | 2 + test/api/cache_store_test.dart | 21 ++ test/state/timetable_reference_data_test.dart | 32 +++ test/utils/recurrence_occurrences_test.dart | 48 +++++ test/view/files/local_cache_search_test.dart | 51 ++--- 70 files changed, 1631 insertions(+), 621 deletions(-) create mode 100644 lib/api/cache_store.dart create mode 100644 lib/utils/json_equality.dart create mode 100644 lib/utils/recurrence_occurrences.dart create mode 100644 lib/utils/screen_bound_image.dart create mode 100644 lib/utils/session_single_flight.dart create mode 100644 test/api/cache_store_test.dart create mode 100644 test/state/timetable_reference_data_test.dart create mode 100644 test/utils/recurrence_occurrences_test.dart diff --git a/lib/api/cache_store.dart b/lib/api/cache_store.dart new file mode 100644 index 0000000..bc97c69 --- /dev/null +++ b/lib/api/cache_store.dart @@ -0,0 +1,185 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; + +/// One cached response: the raw JSON payload and when it was stored. +class CacheEntry { + final String json; + final int lastUpdate; + + const CacheEntry({required this.json, required this.lastUpdate}); +} + +/// File-per-key store behind [RequestCache] and friends. +/// +/// Replaces `localstore` for response caching: localstore reads and decodes +/// the entire collection synchronously on first access, keeps every document +/// in memory for the whole session and JSON-encodes payloads a second time. +/// Here every access is async and touches exactly one file, and the payload is +/// stored verbatim behind a one-line header (`\n`). +class CacheStore { + CacheStore._(); + + static final CacheStore instance = CacheStore._(); + + /// Payloads above this size are decoded on a background isolate. + static const int isolateDecodeThreshold = 64 * 1024; + + Future? _dir; + final Map> _writes = {}; + + Future _directory() => _dir ??= () async { + final base = await getApplicationCacheDirectory(); + final dir = Directory('${base.path}/request_cache'); + await dir.create(recursive: true); + return dir; + }(); + + Future _file(String key) async => + File('${(await _directory()).path}/${_safeName(key)}'); + + /// Keys are internal ids (`nc-chat-`, `wd-folder-`); only path + /// separators would be a problem in a file name. + static String _safeName(String key) => key.replaceAll(RegExp(r'[/\\]'), '_'); + + Future read(String key) async { + try { + await _writes[key]; + return parse(await (await _file(key)).readAsString()); + } on Object { + // Missing (PathNotFoundException) or unreadable: a cache miss. + return null; + } + } + + /// All entries whose key starts with [prefix], read concurrently. + Future> readAll({String prefix = ''}) async { + final keys = await this.keys(prefix: prefix); + final entries = await Future.wait(keys.map(read)); + return {for (var i = 0; i < keys.length; i++) keys[i]: ?entries[i]}; + } + + Future write(String key, String json) { + final previous = _writes[key] ?? Future.value(); + late final Future current; + current = previous.then((_) => _write(key, json)).whenComplete(() { + if (identical(_writes[key], current)) _writes.remove(key); + }); + return _writes[key] = current; + } + + Future _write(String key, String json) async { + try { + final file = await _file(key); + // Write-then-rename so a reader (or the widget background isolate) + // never sees a half-written file. + final tmp = File('${file.path}.tmp'); + await tmp.writeAsString( + '${DateTime.now().millisecondsSinceEpoch}\n$json', + flush: true, + ); + await tmp.rename(file.path); + } on Object catch (e) { + debugPrint('CacheStore.write($key) failed: $e'); + } + } + + Future delete(String key) async { + try { + await _writes[key]; + await (await _file(key)).delete(); + } on Object { + // A missing or locked file is as good as deleted for a cache. + } + } + + Future clear() async { + try { + final dir = await _directory(); + if (dir.existsSync()) await dir.delete(recursive: true); + } on Object catch (e) { + debugPrint('CacheStore.clear failed: $e'); + } + _dir = null; + } + + /// All stored keys, optionally filtered by [prefix]. + Future> keys({String prefix = ''}) async { + try { + final dir = await _directory(); + return [ + await for (final entity in dir.list()) + if (entity is File && !entity.path.endsWith('.tmp')) + if (entity.uri.pathSegments.last case final name + when name.startsWith(prefix)) + name, + ]; + } on Object { + return const []; + } + } + + /// Removes entries not written for [maxAge], judged by file mtime so nothing + /// has to be read or decoded. + Future deleteOlderThan(Duration maxAge) async { + try { + final dir = await _directory(); + final cutoff = DateTime.now().subtract(maxAge); + await for (final entity in dir.list()) { + if (entity is! File) continue; + // ignore: avoid_slow_async_io + final modified = (await entity.stat()).modified; + if (modified.isBefore(cutoff)) await entity.delete(); + } + } on Object catch (e) { + debugPrint('CacheStore.deleteOlderThan failed: $e'); + } + } + + /// Total size of all entries in bytes. + Future totalSize() async { + try { + final dir = await _directory(); + var sum = 0; + await for (final entity in dir.list()) { + if (entity is File) sum += await entity.length(); + } + return sum; + } on Object { + return 0; + } + } + + /// Parses the stored file format; public for tests. + static CacheEntry? parse(String raw) { + final newline = raw.indexOf('\n'); + if (newline < 0) return null; + final lastUpdate = int.tryParse(raw.substring(0, newline)); + if (lastUpdate == null) return null; + return CacheEntry(json: raw.substring(newline + 1), lastUpdate: lastUpdate); + } + + /// Decodes a cached payload, off the UI isolate when it is large. + static Future> decode(String json) => + json.length > isolateDecodeThreshold + ? compute(_decodeMap, json) + : Future.value(_decodeMap(json)); + + static Map _decodeMap(String json) => + jsonDecode(json) as Map; + + /// Deletes the directory the old localstore-based cache lived in. It is + /// never read again and would otherwise linger with up to 200 days of data. + static Future deleteLegacyLocalstoreCache() async { + try { + final docs = await getApplicationDocumentsDirectory(); + final legacy = Directory('${docs.path}/MarianumMobile'); + if (legacy.existsSync()) await legacy.delete(recursive: true); + } on Object catch (e) { + debugPrint('Legacy cache cleanup failed: $e'); + } + } +} diff --git a/lib/api/marianumcloud/talk/chat/get_chat_cache.dart b/lib/api/marianumcloud/talk/chat/get_chat_cache.dart index 1a99d58..2f224cf 100644 --- a/lib/api/marianumcloud/talk/chat/get_chat_cache.dart +++ b/lib/api/marianumcloud/talk/chat/get_chat_cache.dart @@ -17,7 +17,7 @@ class GetChatCache extends SimpleCache { lookIntoFuture: GetChatParamsSwitch.off, setReadMarker: GetChatParamsSwitch.on, // Small initial page; also the per-chat offline snapshot written to - // localstore. Older messages are paged in on scroll-up via + // the request cache. Older messages are paged in on scroll-up via // GetChatHistory. Keep in sync with ChatBloc's _kInitialPageSize. limit: 50, ), diff --git a/lib/api/marianumcloud/talk/chat/get_chat_history.dart b/lib/api/marianumcloud/talk/chat/get_chat_history.dart index ab038dd..dad1d84 100644 --- a/lib/api/marianumcloud/talk/chat/get_chat_history.dart +++ b/lib/api/marianumcloud/talk/chat/get_chat_history.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import '../../../errors/server_exception.dart'; @@ -45,8 +46,10 @@ class GetChatHistory { final status = response.statusCode; if (status == 304) return null; if (status >= 200 && status < 300) { - return GetChatResponse.fromJson(NextcloudOcs.decode(response.body)) - ..headers = response.headers; + // A page holds up to 200 messages and lands while the user is scrolling; + // decoding it on the UI isolate stalls the fling. + final parsed = await compute(_parseChatResponse, response.body); + return parsed..headers = response.headers; } throw ServerException( statusCode: status, @@ -54,3 +57,6 @@ class GetChatHistory { ); } } + +GetChatResponse _parseChatResponse(String body) => + GetChatResponse.fromJson(NextcloudOcs.decode(body)); diff --git a/lib/api/marianumcloud/talk/room/get_room.dart b/lib/api/marianumcloud/talk/room/get_room.dart index 14df26a..8c9d01f 100644 --- a/lib/api/marianumcloud/talk/room/get_room.dart +++ b/lib/api/marianumcloud/talk/room/get_room.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import '../../nextcloud_ocs.dart'; @@ -10,8 +11,12 @@ class GetRoom extends TalkApi { GetRoom(this.params) : super('v4/room', null, getParameters: params.toJson()); @override - GetRoomResponse assemble(String raw) => - GetRoomResponse.fromJson(NextcloudOcs.decode(raw)); + GetRoomResponse assemble(String raw) => _parseRooms(raw); + + // The room list with status is 100–250 KB and polled every 15–60 s. + @override + Future assembleAsync(String raw) => + compute(_parseRooms, raw); @override Future request( @@ -20,3 +25,6 @@ class GetRoom extends TalkApi { Map? headers, ) => http.get(uri, headers: headers); } + +GetRoomResponse _parseRooms(String raw) => + GetRoomResponse.fromJson(NextcloudOcs.decode(raw)); diff --git a/lib/api/marianumcloud/talk/talk_api.dart b/lib/api/marianumcloud/talk/talk_api.dart index 78282bd..64e7e29 100644 --- a/lib/api/marianumcloud/talk/talk_api.dart +++ b/lib/api/marianumcloud/talk/talk_api.dart @@ -30,6 +30,10 @@ abstract class TalkApi { ); T assemble(String raw); + /// Override to parse large payloads off the UI isolate (e.g. via `compute` + /// with a top-level parser); defaults to the synchronous [assemble]. + Future assembleAsync(String raw) async => assemble(raw); + Future run() async { final endpoint = NextcloudOcs.uri( 'apps/spreed/api/$path', @@ -69,7 +73,7 @@ abstract class TalkApi { } try { - final assembled = assemble(data.body); + final assembled = await assembleAsync(data.body); assembled?.headers = data.headers; return assembled; } catch (e) { diff --git a/lib/api/marianumcloud/webdav/queries/list_files/cacheable_file.dart b/lib/api/marianumcloud/webdav/queries/list_files/cacheable_file.dart index b674b10..dcee7b9 100644 --- a/lib/api/marianumcloud/webdav/queries/list_files/cacheable_file.dart +++ b/lib/api/marianumcloud/webdav/queries/list_files/cacheable_file.dart @@ -29,6 +29,19 @@ class CacheableFile { /// file/folder in the list. Nullable so older cached entries decode fine. bool? isSharedWithMe; + /// Lower-cased [name] for sorting/filtering, cached because comparators + /// call it for every comparison (name is mutable, hence the identity check). + String get lowerName { + if (!identical(_lowerNameFor, name)) { + _lowerNameFor = name; + _lowerName = name.toLowerCase(); + } + return _lowerName; + } + + String? _lowerNameFor; + String _lowerName = ''; + CacheableFile({ required this.path, required this.isDirectory, diff --git a/lib/api/marianumcloud/webdav/queries/list_files/list_files_cache.dart b/lib/api/marianumcloud/webdav/queries/list_files/list_files_cache.dart index a6e8178..feba2a7 100644 --- a/lib/api/marianumcloud/webdav/queries/list_files/list_files_cache.dart +++ b/lib/api/marianumcloud/webdav/queries/list_files/list_files_cache.dart @@ -1,9 +1,9 @@ import 'dart:convert'; import 'package:crypto/crypto.dart'; -import 'package:localstore/localstore.dart'; import '../../../../../utils/cache_invalidation_bus.dart'; +import '../../../../cache_store.dart'; import '../../../../request_cache.dart'; import 'list_files.dart'; import 'list_files_params.dart'; @@ -43,14 +43,11 @@ class ListFilesCache extends SimpleCache { /// (slow) root listing is already populated by the time the user /// navigates to the Files module. /// - /// No-ops when a cached root payload is already present in localstore — + /// No-ops when a cached root payload is already present in the cache — /// the regular TTL handling in [RequestCache] takes over from there. static Future prefetchRootListing() async { const rootPath = ''; - final cached = await Localstore.instance - .collection(RequestCache.collection) - .doc(_documentId(rootPath)) - .get(); + final cached = await CacheStore.instance.read(_documentId(rootPath)); if (cached != null) return; // Drive the same code path as a regular fetch so the result lands in // the cache; we don't care about the in-memory callback here. @@ -69,10 +66,7 @@ class ListFilesCache extends SimpleCache { /// `_FilesView` for that path via [CacheInvalidationBus] so it refetches /// even while it is sitting in the background of the navigation stack. static Future invalidate(String path) async { - await Localstore.instance - .collection(RequestCache.collection) - .doc(_documentId(path)) - .delete(); + await CacheStore.instance.delete(_documentId(path)); CacheInvalidationBus.notifyListFiles(path); } } diff --git a/lib/api/marianumconnect/queries/timetable_custom_events/custom_events_migration.dart b/lib/api/marianumconnect/queries/timetable_custom_events/custom_events_migration.dart index 613e598..d26e008 100644 --- a/lib/api/marianumconnect/queries/timetable_custom_events/custom_events_migration.dart +++ b/lib/api/marianumconnect/queries/timetable_custom_events/custom_events_migration.dart @@ -1,6 +1,6 @@ import 'dart:developer'; -import 'package:localstore/localstore.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import '../../../../model/account_data.dart'; import '../../../demo/demo_mode.dart'; @@ -21,9 +21,7 @@ import 'timetable_custom_events_add.dart'; /// failure only sees the events that were not yet moved, so nothing is /// duplicated. The flag is set only once MHSL reports no remaining events. class CustomEventsMigration { - static const String _collection = 'MarianumMobile'; - static const String _document = 'customEventsMigration'; - static const String _doneKey = 'migratedToMc'; + static const String _doneKey = 'customEventsMigratedToMc'; const CustomEventsMigration._(); @@ -57,17 +55,11 @@ class CustomEventsMigration { } } - static Future _isDone() async { - final data = await Localstore.instance - .collection(_collection) - .doc(_document) - .get(); - return data != null && data[_doneKey] == true; - } + // SharedPreferences, like the old cache document, is cleared on sign-out, + // so the next account runs its own migration. + static Future _isDone() async => + (await SharedPreferences.getInstance()).getBool(_doneKey) ?? false; - static Future _markDone() async { - await Localstore.instance.collection(_collection).doc(_document).set({ - _doneKey: true, - }); - } + static Future _markDone() async => + (await SharedPreferences.getInstance()).setBool(_doneKey, true); } diff --git a/lib/api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart b/lib/api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart index e5b5011..50de381 100644 --- a/lib/api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart +++ b/lib/api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart @@ -76,10 +76,17 @@ class McTimetableEntry { /// Combines the calendar date with the hour/minute portion of [startTime] /// (which carries a 1970 placeholder date) into a real DateTime. - DateTime get startDateTime => + DateTime get startDateTime => _startDateTime; + + DateTime get endDateTime => _endDateTime; + + // Cached (private, so json_serializable ignores them): sorting and lesson + // merging read these per comparison, and every local DateTime construction + // does a timezone lookup. + late final DateTime _startDateTime = DateTime(date.year, date.month, date.day, startTime.hour, startTime.minute); - DateTime get endDateTime => + late final DateTime _endDateTime = DateTime(date.year, date.month, date.day, endTime.hour, endTime.minute); static DateTime _dateFromJson(String raw) => DateTime.parse(raw); diff --git a/lib/api/request_cache.dart b/lib/api/request_cache.dart index bdf86ea..e6c2b03 100644 --- a/lib/api/request_cache.dart +++ b/lib/api/request_cache.dart @@ -1,10 +1,9 @@ import 'dart:async'; import 'dart:convert'; -import 'package:localstore/localstore.dart'; - import '../model/account_data.dart'; import 'api_response.dart'; +import 'cache_store.dart'; import 'errors/parse_exception.dart'; import 'errors/stale_session_exception.dart'; @@ -14,12 +13,10 @@ abstract class RequestCache { static const int cacheHour = 60 * 60; static const int cacheDay = 60 * 60 * 24; - static String collection = 'MarianumMobile'; - int maxCacheTime; void Function(T)? onUpdate; - /// Called only when [start] finds a cached payload in localstore. Use this + /// Called only when [start] finds a cached payload in the [CacheStore]. Use this /// (instead of [onUpdate]) when callers need to distinguish stale-but-fast /// cache hits from authoritative network responses. void Function(T)? onCacheData; @@ -52,47 +49,55 @@ abstract class RequestCache { Future start(String document) async { final epoch = AccountData().sessionEpoch; try { - final tableData = await Localstore.instance - .collection(collection) - .doc(document) - .get(); - if (tableData != null) { - final cached = onLocalData(tableData['json'] as String); + final entry = await CacheStore.instance.read(document); + var lastUpdate = entry?.lastUpdate ?? 0; + T? cached; + if (entry != null) { + try { + cached = fromCacheJson(await CacheStore.decode(entry.json)); + } on Object { + // A payload from an older model version: treat it as a miss. + await CacheStore.instance.delete(document); + lastUpdate = 0; + } + } + if (cached != null) { onUpdate?.call(cached); onCacheData?.call(cached); } - - final lastUpdate = (tableData?['lastupdate'] as num?) ?? 0; - if (DateTime.now().millisecondsSinceEpoch - (maxCacheTime * 1000) < - lastUpdate) { - if (renew == null || !renew!) return; - } - - try { - final newValue = await onLoad(); - // The collection is shared, so a late response of a signed-out - // account would otherwise be cached for the next one. - if (!AccountData().isCurrentSession(epoch)) { - onError(const StaleSessionException()); - return; - } - onUpdate?.call(newValue); - onNetworkData?.call(newValue); - unawaited( - Localstore.instance.collection(collection).doc(document).set({ - 'json': jsonEncode(newValue), - 'lastupdate': DateTime.now().millisecondsSinceEpoch, - }), - ); - } on Exception catch (e) { - onError(e); - } + await _load(document, epoch, lastUpdate: lastUpdate); } finally { if (!_ready.isCompleted) _ready.complete(); } } - T onLocalData(String json); + Future _load( + String document, + int epoch, { + required int lastUpdate, + }) async { + if (DateTime.now().millisecondsSinceEpoch - (maxCacheTime * 1000) < + lastUpdate) { + if (renew == null || !renew!) return; + } + + try { + final newValue = await onLoad(); + // The cache is shared, so a late response of a signed-out + // account would otherwise be cached for the next one. + if (!AccountData().isCurrentSession(epoch)) { + onError(const StaleSessionException()); + return; + } + onUpdate?.call(newValue); + onNetworkData?.call(newValue); + unawaited(CacheStore.instance.write(document, jsonEncode(newValue))); + } on Exception catch (e) { + onError(e); + } + } + + T fromCacheJson(Map json); Future onLoad(); } @@ -126,8 +131,7 @@ class SimpleCache extends RequestCache { Future onLoad() => _loader(); @override - T onLocalData(String json) => - _fromJson(jsonDecode(json) as Map); + T fromCacheJson(Map json) => _fromJson(json); } /// Captures the latest cache payload (cached or network) and rethrows the diff --git a/lib/app.dart b/lib/app.dart index c8ae5d1..e6bb8a4 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -10,6 +10,7 @@ import 'api/marianumconnect/marianumconnect_api.dart'; import 'api/marianumconnect/queries/get_breakers/get_breakers_response.dart'; import 'api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart'; import 'main.dart'; +import 'model/account_data.dart'; import 'model/data_cleaner.dart'; import 'notification/notification_controller.dart'; import 'notification/notification_tasks.dart'; @@ -40,7 +41,6 @@ class App extends StatefulWidget { } class _AppState extends State with WidgetsBindingObserver { - late Timer _updateTimings; StreamSubscription? _timetableWidgetSync; StreamSubscription? _onMessageSub; StreamSubscription? _onMessageOpenedAppSub; @@ -74,7 +74,11 @@ class _AppState extends State with WidgetsBindingObserver { bloc.setAutoRefreshInterval( talkIsActive ? _chatListActiveInterval : _chatListIdleInterval, ); - if (talkIsActive && refresh) bloc.refresh(); + // The poll keeps the list current; switching back to the tab shortly + // after a fetch doesn't need another one mid tab transition. + if (talkIsActive && refresh) { + bloc.refreshIfOlderThan(_chatListActiveInterval); + } } // Wall-clock throttle rather than Debouncer.throttle: a Timer does not tick @@ -110,6 +114,7 @@ class _AppState extends State with WidgetsBindingObserver { _syncChatListPolling(refresh: false); _handlePendingWidgetNavigation(); } else if (mounted) { + context.read().flushSilentSave(); // Stop polling while backgrounded: a silent refresh failing in the // background would otherwise leave an error that flashes on the next // resume before the foreground refetch replaces it. @@ -179,26 +184,42 @@ class _AppState extends State with WidgetsBindingObserver { final settingsCubit = context.read(); final capabilitiesCubit = context.read(); _timetableWidgetSync?.cancel(); + // Debounced: a refresh or week swipe emits several times in a row, and + // only the settled state is worth mirroring. _timetableWidgetSync = timetable.stream.listen((state) { final data = state.data; if (data is TimetableState && !state.isLoading) { - unawaited( - WidgetPublisher.publishFromBlocState( - data, - settings: settingsCubit.val(), - isTeacher: capabilitiesCubit.isTeacher, + final epoch = AccountData().sessionEpoch; + Debouncer.debounce( + 'widgetPublish', + const Duration(seconds: 1), + () => unawaited( + WidgetPublisher.publishFromBlocState( + data, + settings: settingsCubit.val(), + isTeacher: capabilitiesCubit.isTeacher, + epoch: epoch, + ), ), ); } }); - // Initial publish in case hydrated storage already has data. + // Initial publish in case hydrated storage already has data. The widget + // still shows its last snapshot, so this waits until the cold-start + // frames are done (a fresh bloc emit in the meantime supersedes it). final initialData = timetable.state.data; if (initialData is TimetableState) { - unawaited( - WidgetPublisher.publishFromBlocState( - initialData, - settings: settingsCubit.val(), - isTeacher: capabilitiesCubit.isTeacher, + final epoch = AccountData().sessionEpoch; + Debouncer.debounce( + 'widgetPublish', + const Duration(seconds: 3), + () => unawaited( + WidgetPublisher.publishFromBlocState( + initialData, + settings: settingsCubit.val(), + isTeacher: capabilitiesCubit.isTeacher, + epoch: epoch, + ), ), ); } @@ -208,10 +229,6 @@ class _AppState extends State with WidgetsBindingObserver { _syncChatListPolling(); }); - _updateTimings = Timer.periodic(const Duration(seconds: 30), (_) { - if (mounted) setState(() {}); - }); - _reportTelemetry(); // A refreshed FCM token invalidates the existing push subscription — the @@ -250,12 +267,15 @@ class _AppState extends State with WidgetsBindingObserver { NotificationController.onAppOpenedByNotification(message, context); }); - DataCleaner.cleanOldCache(); + // Housekeeping only; kept out of the cold-start window. + Future.delayed( + const Duration(seconds: 20), + DataCleaner.cleanOldCache, + ); } @override void dispose() { - _updateTimings.cancel(); _timetableWidgetSync?.cancel(); _onMessageSub?.cancel(); _onMessageOpenedAppSub?.cancel(); @@ -271,7 +291,18 @@ class _AppState extends State with WidgetsBindingObserver { @override Widget build( BuildContext context, - ) => BlocBuilder( + ) => BlocSelector( + // Only the module layout shapes the shell; copied values because the + // settings object is mutated in place. + selector: (settings) { + final m = settings.modulesSettings; + return ( + m.moduleOrder.join(','), + m.hiddenModules.join(','), + m.autoFillBottomBar, + m.fixedBottomBarSlots, + ); + }, builder: (context, _) { final bottomBarModules = AppModule.getBottomBarModules(context); final totalTabs = bottomBarModules.length + 1; diff --git a/lib/extensions/text.dart b/lib/extensions/text.dart index 879e7ec..4459d82 100644 --- a/lib/extensions/text.dart +++ b/lib/extensions/text.dart @@ -1,16 +1,29 @@ import 'package:flutter/material.dart'; extension TextExt on Text { - Size get size { - final textPainter = TextPainter( + /// Single-line width as rendered in [context] (honours the text scaler). + /// Memoised: chat bubbles measure the same few names and times over and + /// over, and every measurement is a full paragraph layout. + double measuredWidth(BuildContext context) { + final scaler = MediaQuery.textScalerOf(context); + final key = (data, style, scaler); + final cached = _widthCache[key]; + if (cached != null) return cached; + final painter = TextPainter( text: TextSpan(text: data, style: style), maxLines: 1, textDirection: TextDirection.ltr, - )..layout(minWidth: 0, maxWidth: double.infinity); - return textPainter.size; + textScaler: scaler, + )..layout(); + final width = painter.width; + painter.dispose(); + if (_widthCache.length >= 512) _widthCache.clear(); + return _widthCache[key] = width; } } +final Map<(String?, TextStyle?, TextScaler), double> _widthCache = {}; + /// Returns the first non-empty (after trim) entry, or '' if none match. String firstNonEmpty(List values) { for (final v in values) { diff --git a/lib/main.dart b/lib/main.dart index 235cb10..2aee13f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -167,6 +167,12 @@ Future main() async { await Future.wait(initialisationTasks); log('app initialisation done!'); + // Independent of the notification setup below, so it runs alongside it. + final widgetSyncInit = _startupStep( + 'widget sync', + WidgetSync.ensureInitialized, + ); + // 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. @@ -186,9 +192,9 @@ Future main() async { ), ); - // 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); + // The home-screen widget bridge must be ready before runApp so any widget + // render triggered during startup hits initialised native storage. + await widgetSyncInit; unawaited( WidgetBackgroundTask.initialize().onError( (e, _) => log('Workmanager init failed: $e'), @@ -271,6 +277,10 @@ class Main extends StatefulWidget { } class _MainState extends State
{ + final List _navigatorObservers = [ + AppRoutes.chatRouteObserver, + DownloadRouteObserver(), + ]; bool _showPostLoginSplash = false; bool _appMounted = true; late AccountStatus _lastStatus; @@ -364,39 +374,45 @@ class _MainState extends State
{ @override Widget build(BuildContext context) => Directionality( textDirection: TextDirection.ltr, - child: BlocBuilder( - builder: (context, settings) { - final devToolsSettings = settings.devToolsSettings; + // Selects only what the root uses: Settings is mutated in place and + // re-emitted as a fresh instance on every write, so a plain BlocBuilder + // would rebuild MaterialApp (and every route) for unrelated settings. + child: BlocSelector( + selector: (settings) => ( + appTheme: settings.appTheme, + mcBaseUrl: settings.devToolsSettings.resolveMarianumConnectBaseUrl(), + notificationsEnabled: settings.notificationSettings.enabled, + showPerformanceOverlay: + settings.devToolsSettings.showPerformanceOverlay, + checkerboardOffscreenLayers: + settings.devToolsSettings.checkerboardOffscreenLayers, + checkerboardRasterCacheImages: + settings.devToolsSettings.checkerboardRasterCacheImages, + ), + builder: (context, root) { // Keep the MC dio singleton aligned with the currently selected // endpoint (live / beta / custom). Idempotent when the URL is - // unchanged so it's safe to call on every rebuild. Mirrored into - // WidgetSync so the background isolate refreshes against the same - // endpoint. - final mcBaseUrl = devToolsSettings.resolveMarianumConnectBaseUrl(); - MarianumConnectEndpoint.update(mcBaseUrl); - unawaited(WidgetSync.setMarianumConnectBaseUrl(mcBaseUrl)); + // unchanged. Mirrored into WidgetSync so the background isolate + // refreshes against the same endpoint. + MarianumConnectEndpoint.update(root.mcBaseUrl); + unawaited(WidgetSync.setMarianumConnectBaseUrl(root.mcBaseUrl)); // 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, + root.notificationsEnabled, ), ); return MaterialApp( - showPerformanceOverlay: devToolsSettings.showPerformanceOverlay, - checkerboardOffscreenLayers: - devToolsSettings.checkerboardOffscreenLayers, - checkerboardRasterCacheImages: - devToolsSettings.checkerboardRasterCacheImages, + showPerformanceOverlay: root.showPerformanceOverlay, + checkerboardOffscreenLayers: root.checkerboardOffscreenLayers, + checkerboardRasterCacheImages: root.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(), - ], + navigatorObservers: _navigatorObservers, localizationsDelegates: const [ ...GlobalMaterialLocalizations.delegates, GlobalWidgetsLocalizations.delegate, @@ -404,7 +420,7 @@ class _MainState extends State
{ supportedLocales: const [Locale('de'), Locale('en')], locale: const Locale('de'), title: 'Marianum Fulda', - themeMode: settings.appTheme, + themeMode: root.appTheme, theme: LightAppTheme.theme, darkTheme: DarkAppTheme.theme, // Brand-colored backdrop behind every route. During the logout @@ -527,3 +543,12 @@ class _MainState extends State
{ ), ); } + +typedef _RootSettings = ({ + ThemeMode appTheme, + String mcBaseUrl, + bool notificationsEnabled, + bool showPerformanceOverlay, + bool checkerboardOffscreenLayers, + bool checkerboardRasterCacheImages, +}); diff --git a/lib/model/account_data.dart b/lib/model/account_data.dart index 518d5fb..567b01e 100644 --- a/lib/model/account_data.dart +++ b/lib/model/account_data.dart @@ -228,16 +228,25 @@ class AccountData { Future _migrateAndLoad() async { await _migrateFromLegacyStorage(); await _migrateKeychainAccessibility(); - _username = await _secureStorage.read(key: _usernameField); - _password = await _secureStorage.read(key: _passwordField); - _isDemo = (await _secureStorage.read(key: _demoField)) == 'true'; - _usesLoginFlow = - (await _secureStorage.read(key: _loginFlowField)) == 'true'; + // Independent keystore reads, each a platform-channel round trip with + // decryption: issued together since this gates the first frame. + final (username, password, demo, loginFlow) = await ( + _secureStorage.read(key: _usernameField), + _secureStorage.read(key: _passwordField), + _secureStorage.read(key: _demoField), + _secureStorage.read(key: _loginFlowField), + ).wait; + _username = username; + _password = password; + _isDemo = demo == 'true'; + _usesLoginFlow = loginFlow == 'true'; try { - _appPassword = await pushSecureStorage.read(key: _appPasswordField); - _appPasswordTalk = await pushSecureStorage.read( - key: _appPasswordTalkField, - ); + final (appPassword, appPasswordTalk) = await ( + pushSecureStorage.read(key: _appPasswordField), + pushSecureStorage.read(key: _appPasswordTalkField), + ).wait; + _appPassword = appPassword; + _appPasswordTalk = appPasswordTalk; } on Object { _appPassword = null; _appPasswordTalk = null; diff --git a/lib/model/data_cleaner.dart b/lib/model/data_cleaner.dart index 925980c..d24c197 100644 --- a/lib/model/data_cleaner.dart +++ b/lib/model/data_cleaner.dart @@ -1,24 +1,8 @@ -import 'package:localstore/localstore.dart'; - -import '../api/request_cache.dart'; +import '../api/cache_store.dart'; class DataCleaner { static Future cleanOldCache() async { - final cacheData = await Localstore.instance - .collection(RequestCache.collection) - .get(); - cacheData?.forEach((key, value) async { - final lastUpdate = DateTime.fromMillisecondsSinceEpoch( - ((value['lastupdate'] as num?) ?? 0).toInt(), - ); - if (DateTime.now() - .subtract(const Duration(days: 200)) - .isAfter(lastUpdate)) { - await Localstore.instance - .collection(RequestCache.collection) - .doc(key.split('/').last) - .delete(); - } - }); + await CacheStore.deleteLegacyLocalstoreCache(); + await CacheStore.instance.deleteOlderThan(const Duration(days: 200)); } } diff --git a/lib/model/session_wipe.dart b/lib/model/session_wipe.dart index b54eff8..644f97a 100644 --- a/lib/model/session_wipe.dart +++ b/lib/model/session_wipe.dart @@ -8,6 +8,7 @@ import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../api/cache_store.dart'; import '../api/marianumcloud/talk/share_files_to_chat.dart'; import '../background/widget_background_task.dart'; import '../notification/notification_service.dart'; @@ -26,7 +27,6 @@ import '../state/app/modules/timetable/bloc/timetable_bloc.dart'; import '../utils/app_paths.dart'; import '../utils/downloads/download_manager.dart'; import '../utils/file_clipboard.dart'; -import '../widget/debug/cache_view.dart'; import '../widget_data/widget_sync.dart'; /// Removes everything the signed-out account left on the device. Every step @@ -98,7 +98,7 @@ abstract final class SessionWipe { await (await SharedPreferences.getInstance()).clear(); }); await _stepAsync('hydrated storage', HydratedBloc.storage.clear); - await _stepAsync('request cache', const CacheView().clear); + await _stepAsync('request cache', CacheStore.instance.clear); await _stepAsync('chat background', () async { final image = File(AppPaths.chatBackgroundImage); if (image.existsSync()) await image.delete(); diff --git a/lib/notification/notification_controller.dart b/lib/notification/notification_controller.dart index 57f4d8a..817ce2f 100644 --- a/lib/notification/notification_controller.dart +++ b/lib/notification/notification_controller.dart @@ -7,6 +7,8 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../push/push_message_handler.dart'; import '../routing/app_routes.dart'; import '../state/app/modules/chat/bloc/chat_bloc.dart'; +import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; +import '../utils/debouncer.dart'; import '../widget/debug/debug_tile.dart'; import '../widget/debug/json_viewer.dart'; import '../widget/info_dialog.dart'; @@ -35,7 +37,14 @@ class NotificationController { ); await NotificationTasks.refreshBadge(); if (!context.mounted) return; - NotificationTasks.updateProviders(context); + // A busy group chat delivers pushes in bursts; one silent refresh after + // the burst replaces a full room-list fetch per message. + final chatList = context.read(); + Debouncer.debounce( + 'pushChatListRefresh', + const Duration(seconds: 1), + () => chatList.refresh(silent: true), + ); } static Future onAppOpenedByNotification( diff --git a/lib/push/push_decryptor.dart b/lib/push/push_decryptor.dart index 7f7785c..a2a49a6 100644 --- a/lib/push/push_decryptor.dart +++ b/lib/push/push_decryptor.dart @@ -1,7 +1,7 @@ import 'dart:convert'; -import 'dart:typed_data'; import 'package:crypton/crypton.dart'; +import 'package:flutter/foundation.dart'; import 'package:pointycastle/export.dart' as pc; import 'push_subject.dart'; @@ -25,6 +25,14 @@ class PushDecryptor { const PushDecryptor({required this.devicePrivateKey, this.serverPublicKey}); + /// [verify] + [decrypt] on a background isolate: pure-Dart RSA-2048 (with + /// the OAEP → PKCS#1 fallback) takes tens of ms up to >100 ms on older + /// phones, which would otherwise land on the UI thread for every push. + Future<({bool verified, PushSubject? subject})> verifyAndDecryptInBackground( + String subjectBase64, + String signatureBase64, + ) => compute(_verifyAndDecrypt, (this, subjectBase64, signatureBase64)); + /// Returns true when [signatureBase64] is a valid server signature over the /// encrypted subject. Returns true when no server key is configured (the /// proxy already verified the signature before forwarding). @@ -75,3 +83,13 @@ class PushDecryptor { } } } + +({bool verified, PushSubject? subject}) _verifyAndDecrypt( + (PushDecryptor, String, String) args, +) { + final (decryptor, subject, signature) = args; + if (!decryptor.verify(subject, signature)) { + return (verified: false, subject: null); + } + return (verified: true, subject: decryptor.decrypt(subject)); +} diff --git a/lib/push/push_message_handler.dart b/lib/push/push_message_handler.dart index 277c2c0..f53c11b 100644 --- a/lib/push/push_message_handler.dart +++ b/lib/push/push_message_handler.dart @@ -178,11 +178,15 @@ class PushMessageHandler { devicePrivateKey: privateKey, serverPublicKey: serverPublicKey, ); - if (!decryptor.verify(subjectBase64, signatureBase64)) { + final result = await decryptor.verifyAndDecryptInBackground( + subjectBase64, + signatureBase64, + ); + if (!result.verified) { log('Push: signature verification failed'); return; } - final subject = decryptor.decrypt(subjectBase64); + final subject = result.subject; if (subject == null) { log('Push: could not decrypt subject'); return; diff --git a/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart b/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart index 9c07d3b..570c155 100644 --- a/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart +++ b/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart @@ -20,10 +20,15 @@ class LoadableStateBloc extends Bloc LoadableStateBloc() : super(const LoadableStateState(connections: null)) { on((event, emit) { + // Only a real reconnect (or a resume) warrants a refetch: the initial + // status after mount and Wi-Fi ↔ mobile handovers would otherwise + // reload an already loaded page for nothing. + final wasOffline = connectivityStatusKnown() && !isConnected(); emit(event.state); - if (connectivityStatusKnown() && isConnected()) { - if (reFetch == null) return; - reFetch!(); + if ((wasOffline || event.fromResume) && + connectivityStatusKnown() && + isConnected()) { + reFetch?.call(); } }); @@ -54,7 +59,12 @@ class LoadableStateBloc extends Bloc unawaited( Connectivity().checkConnectivity().then((result) { if (isClosed) return; - add(ConnectivityChanged(LoadableStateState(connections: result))); + add( + ConnectivityChanged( + LoadableStateState(connections: result), + fromResume: true, + ), + ); }), ); } diff --git a/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_event.dart b/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_event.dart index 40955b6..8924fb0 100644 --- a/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_event.dart +++ b/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_event.dart @@ -4,5 +4,10 @@ sealed class LoadableStateEvent {} final class ConnectivityChanged extends LoadableStateEvent { final LoadableStateState state; - ConnectivityChanged(this.state); + + /// Re-check after the app came back to the foreground: refetches whenever + /// online, not only on an offline → online transition. + final bool fromResume; + + ConnectivityChanged(this.state, {this.fromResume = false}); } diff --git a/lib/state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart b/lib/state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart index 7ddfa98..d18a7ae 100644 --- a/lib/state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart +++ b/lib/state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart @@ -69,7 +69,7 @@ class LoadableStateConsumer< // null mid-refetch, and toggling the RefreshIndicator on that signal would // rebuild the tree under the ListView and reset its scroll position. final content = SizedBox( - height: MediaQuery.of(context).size.height, + height: MediaQuery.sizeOf(context).height, child: hasContent ? child(typedData as TState, isLoading) : const SizedBox.shrink(), diff --git a/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_bar.dart b/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_bar.dart index 417af24..df6eaef 100644 --- a/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_bar.dart +++ b/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_bar.dart @@ -95,10 +95,12 @@ class _LoadableStateErrorBarTextState extends State { late Timer _rebuildTimer; @override void initState() { - _rebuildTimer = Timer.periodic( - const Duration(seconds: 10), - (timer) => setState(() {}), - ); + // Only refresh the relative "last updated" text while this page is the + // visible one; offstage tabs and covered routes have tickers disabled. + _rebuildTimer = Timer.periodic(const Duration(seconds: 10), (timer) { + if (!mounted) return; + if (TickerMode.getValuesNotifier(context).value.enabled) setState(() {}); + }); super.initState(); } diff --git a/lib/state/app/infrastructure/loadable_state/view/loadable_state_primary_loading.dart b/lib/state/app/infrastructure/loadable_state/view/loadable_state_primary_loading.dart index 2b70ead..23b14b2 100644 --- a/lib/state/app/infrastructure/loadable_state/view/loadable_state_primary_loading.dart +++ b/lib/state/app/infrastructure/loadable_state/view/loadable_state_primary_loading.dart @@ -33,6 +33,9 @@ class _LoadableStatePrimaryLoadingState extends State { Timer? _slowHintTimer; bool _showSlowHint = false; + // An indeterminate spinner ticks every frame even at opacity 0, so it is + // unmounted once the fade-out finished instead of just being hidden. + late bool _spinnerMounted = widget.visible; @override void initState() { @@ -44,6 +47,7 @@ class _LoadableStatePrimaryLoadingState void didUpdateWidget(covariant LoadableStatePrimaryLoading oldWidget) { super.didUpdateWidget(oldWidget); if (widget.visible != oldWidget.visible) _restartSlowHintTimer(); + if (widget.visible) _spinnerMounted = true; } void _restartSlowHintTimer() { @@ -62,43 +66,49 @@ class _LoadableStatePrimaryLoadingState } @override - Widget build(BuildContext context) { + Widget build(BuildContext context) => AnimatedOpacity( + opacity: widget.visible ? 1.0 : 0.0, + duration: LoadableStateConsumer.animationDuration, + curve: Curves.easeInOut, + onEnd: () { + if (!widget.visible && _spinnerMounted) { + setState(() => _spinnerMounted = false); + } + }, + child: _spinnerMounted ? _spinner(context) : const SizedBox.shrink(), + ); + + Widget _spinner(BuildContext context) { final status = widget.statusText ?? (_showSlowHint ? LoadableStatePrimaryLoading.slowHintText : null); - - return AnimatedOpacity( - opacity: widget.visible ? 1.0 : 0.0, - duration: LoadableStateConsumer.animationDuration, - curve: Curves.easeInOut, - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const AppProgressIndicator.large(), - AnimatedSwitcher( - duration: LoadableStateConsumer.animationDuration, - child: status == null - ? const SizedBox.shrink() - : Padding( - key: ValueKey(status), - padding: const EdgeInsets.only( - top: 16, - left: 24, - right: 24, - ), - child: Text( - status, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 13, - color: Theme.of(context).hintColor, - ), + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const AppProgressIndicator.large(), + AnimatedSwitcher( + duration: LoadableStateConsumer.animationDuration, + child: status == null + ? const SizedBox.shrink() + : Padding( + key: ValueKey(status), + padding: const EdgeInsets.only( + top: 16, + left: 24, + right: 24, + ), + child: Text( + status, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, + color: Theme.of(context).hintColor, ), ), - ), - ], - ), + ), + ), + ], ), ); } diff --git a/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart index 0234055..78df01d 100644 --- a/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart +++ b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart @@ -6,6 +6,7 @@ import 'package:hydrated_bloc/hydrated_bloc.dart'; import '../../../../../api/errors/error_mapper.dart'; import '../../../../../api/errors/stale_session_exception.dart'; import '../../../../../model/account_data.dart'; +import '../../../../../utils/session_single_flight.dart'; import '../../loadable_state/loadable_state.dart'; import '../../loadable_state/loading_error.dart'; import '../../repository/repository.dart'; @@ -20,6 +21,12 @@ abstract class LoadableHydratedBloc< extends HydratedBloc, LoadableState> { late TRepository _repository; + + // HydratedBloc serialises and writes the full state on every emit; loading + // flags, status texts and errors re-emit the very same data, so those + // writes are skipped (see [persistenceKey]). + Object? _lastPersistedKey; + int? _lastPersistedFetch; LoadableHydratedBloc() : super( const LoadableState( @@ -113,6 +120,8 @@ abstract class LoadableHydratedBloc< /// fresh [fetch] (e.g. via [retry] or page-specific refresh) once the user /// is authenticated again, otherwise the UI would stay blank. Future reset() async { + _lastPersistedKey = null; + _lastPersistedFetch = null; await clear(); add(Reset()); } @@ -122,10 +131,8 @@ abstract class LoadableHydratedBloc< /// Runs [body] tagged with the current session: events it adds, also from /// its async continuations, are dropped once the account signed out, so a /// late response of the previous account cannot refill the reset bloc. - R runInSession(R Function() body) => runZoned( - body, - zoneValues: {_sessionKey: AccountData().sessionEpoch}, - ); + R runInSession(R Function() body) => + runZoned(body, zoneValues: {_sessionKey: AccountData().sessionEpoch}); @override void add(LoadableHydratedBlocEvent event) { @@ -160,20 +167,31 @@ abstract class LoadableHydratedBloc< ); } + // The constructor, the app shell and resume/reconnect handlers can all ask + // at once; each parallel gather would re-parse, re-emit and re-persist the + // same data. + final SessionSingleFlight _fetchFlight = SessionSingleFlight(); + void fetch() { - log('Fetching data for ${TState.toString()}'); - runInSession( - () => gatherData() - .catchError((Object e) { - log('Error while fetching ${TState.toString()}: ${e.toString()}'); - // The bloc may have been closed before this async error landed; - // adding to a closed bloc throws, so swallow that case. - if (isClosed) return; - addLoadingError(e); - }) - .then((value) { - log('Fetch for ${TState.toString()} completed!'); - }), + unawaited( + _fetchFlight.run(() { + log('Fetching data for ${TState.toString()}'); + return runInSession( + () => gatherData() + .catchError((Object e) { + log( + 'Error while fetching ${TState.toString()}: ${e.toString()}', + ); + // The bloc may have been closed before this async error + // landed; adding to a closed bloc throws, so swallow that case. + if (isClosed) return; + addLoadingError(e); + }) + .then((value) { + log('Fetch for ${TState.toString()} completed!'); + }), + ); + }), ); } @@ -191,13 +209,27 @@ abstract class LoadableHydratedBloc< @override Map? toJson(LoadableState state) { + final stateData = state.data; + final key = stateData is TState ? persistenceKey(stateData) : null; + if (key != null && + // Identity for plain data: a freezed `==` would deep-compare it. + (identical(key, _lastPersistedKey) || + (key is Record && key == _lastPersistedKey)) && + state.lastFetch == _lastPersistedFetch) { + return null; + } + _lastPersistedKey = key; + _lastPersistedFetch = state.lastFetch; + Map? data; try { - final stateData = state.data; data = stateData is TState ? toStorage(stateData) : null; } catch (e) { log('Failed to save state ${TState.toString()}: ${e.toString()}'); } + // Blocs that keep nothing on disk return null from toStorage; writing an + // empty wrapper per emit would be pure overhead. + if (stateData != null && data == null) return null; return LoadableSaveContext.wrap( data, @@ -205,6 +237,13 @@ abstract class LoadableHydratedBloc< ); } + /// What has to change for the state to be written to disk again. Defaults + /// to the data instance; blocs whose state also carries transient UI flags + /// can return a record of just the persisted parts (records compare with + /// `==`, so use fields that compare cheaply, e.g. by identity). + Object? persistenceKey(TState data) => data; + + Future gatherData(); TRepository repository(); diff --git a/lib/state/app/modules/chat/bloc/chat_bloc.dart b/lib/state/app/modules/chat/bloc/chat_bloc.dart index a36acfc..ebba68a 100644 --- a/lib/state/app/modules/chat/bloc/chat_bloc.dart +++ b/lib/state/app/modules/chat/bloc/chat_bloc.dart @@ -59,6 +59,12 @@ class ChatBloc @override ChatState fromStorage(Map json) => ChatState.fromJson(json); + // Loading-older and reply-reference flips must not re-persist up to 500 + // messages; GetChatResponse has no `==`, so it compares by identity here. + @override + Object? persistenceKey(ChatState data) => + (data.currentToken, data.chatResponse, data.hasMoreOld); + @override Map? toStorage(ChatState state) { final response = state.chatResponse; diff --git a/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart b/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart index d9aec94..c1cd1f5 100644 --- a/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart +++ b/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:developer'; import 'package:flutter_app_badge/flutter_app_badge.dart'; @@ -6,6 +7,7 @@ import 'package:flutter_app_badge/flutter_app_badge.dart'; import '../../../../../api/marianumcloud/talk/actions/talk_actions.dart'; import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; import '../../../../../api/marianumcloud/talk/room/get_room_response.dart'; +import '../../../../../utils/session_single_flight.dart'; import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart'; import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; import '../repository/chat_list_repository.dart'; @@ -74,13 +76,32 @@ class ChatListBloc renew: renew, onError: (e) => capturedError = e, ); + _lastRoomsJson = null; add(DataGathered((s) => s.copyWith(rooms: rooms))); _updateAppBadge(rooms); if (capturedError != null) throw capturedError!; } - Future refresh({bool renew = true, bool silent = false}) async { + final SessionSingleFlight _refreshFlight = SessionSingleFlight(); + + /// Concurrent callers (tab switch, poll, push, resume) join the running + /// refresh instead of each fetching and re-emitting the full room list. + Future refresh({bool renew = true, bool silent = false}) => + _refreshFlight.run(() => _refresh(renew: renew, silent: silent)); + + /// Skips the refresh when the list was fetched within [maxAge]. + Future refreshIfOlderThan(Duration maxAge) { + final lastFetch = state.lastFetch; + if (lastFetch != null && + DateTime.now().millisecondsSinceEpoch - lastFetch < + maxAge.inMilliseconds) { + return Future.value(); + } + return refresh(); + } + + Future _refresh({required bool renew, required bool silent}) async { if (!silent) add(RefetchStarted()); Object? capturedError; try { @@ -88,6 +109,11 @@ class ChatListBloc renew: renew, onError: (e) => capturedError = e, ); + if (silent) { + if (_isUnchanged(rooms)) return; + } else { + _lastRoomsJson = null; + } add(DataGathered((s) => s.copyWith(rooms: rooms))); _updateAppBadge(rooms); } catch (e) { @@ -96,6 +122,23 @@ class ChatListBloc if (capturedError != null) addLoadingError(capturedError!); } + // Encoded room data of the last applied poll result; response headers are + // left out because they differ on every request. + String? _lastRoomsJson; + + /// A background poll that returns the same rooms would otherwise rebuild the + /// list and re-persist the whole state every 15 s. + bool _isUnchanged(GetRoomResponse rooms) { + final encoded = jsonEncode(rooms.data); + final unchanged = + encoded == _lastRoomsJson && + innerState?.rooms != null && + state.error == null && + !state.isLoading; + _lastRoomsJson = encoded; + return unchanged; + } + /// Creates (or resolves) a 1:1 chat and returns its room token, or null in /// demo mode. Refreshes the list so the room shows up. Future createDirectChat(String invite) async { @@ -171,6 +214,7 @@ class ChatListBloc }).toSet(); if (!changed) return; final newRooms = GetRoomResponse(updated)..headers = rooms.headers; + _lastRoomsJson = null; add(Emit((s) => s.copyWith(rooms: newRooms))); _updateAppBadge(newRooms); } diff --git a/lib/state/app/modules/foreign_timetable/bloc/foreign_timetable_bloc.dart b/lib/state/app/modules/foreign_timetable/bloc/foreign_timetable_bloc.dart index 4a58546..98f3bd3 100644 --- a/lib/state/app/modules/foreign_timetable/bloc/foreign_timetable_bloc.dart +++ b/lib/state/app/modules/foreign_timetable/bloc/foreign_timetable_bloc.dart @@ -142,12 +142,11 @@ class ForeignTimetableBloc add( Emit( - (s) => s.copyWith( + (s) => s.withReferenceData( rooms: rooms, subjects: subjects, schoolHolidays: schoolHolidays, schoolyear: schoolyear, - dataVersion: s.dataVersion + 1, ), ), ); @@ -157,11 +156,7 @@ class ForeignTimetableBloc try { final timegrid = await repo.data.getTimegrid(); - add( - Emit( - (s) => s.copyWith(timegrid: timegrid, dataVersion: s.dataVersion + 1), - ), - ); + add(Emit((s) => s.withReferenceData(timegrid: timegrid))); } catch (_) { // Timegrid load failure falls back to a hardcoded schedule in the UI. } diff --git a/lib/state/app/modules/marianum_dates/data_provider/marianum_dates_get_events.dart b/lib/state/app/modules/marianum_dates/data_provider/marianum_dates_get_events.dart index 63bcbbb..34793a6 100644 --- a/lib/state/app/modules/marianum_dates/data_provider/marianum_dates_get_events.dart +++ b/lib/state/app/modules/marianum_dates/data_provider/marianum_dates_get_events.dart @@ -1,5 +1,6 @@ import 'package:dio/dio.dart'; import 'package:enough_icalendar/enough_icalendar.dart'; +import 'package:flutter/foundation.dart'; import '../bloc/marianum_dates_state.dart'; @@ -19,6 +20,12 @@ class MarianumDatesGetEvents { final body = response.data; if (body == null || body.isEmpty) return []; + // The public feed holds hundreds of events back to 1981; parsing it on + // the UI isolate froze the page for a noticeable moment on every open. + return compute(parseEvents, body); + } + + static List parseEvents(String body) { final root = VComponent.parse(body); final calendar = root is VCalendar ? root : null; final source = calendar?.children ?? root.children; diff --git a/lib/state/app/modules/settings/bloc/settings_cubit.dart b/lib/state/app/modules/settings/bloc/settings_cubit.dart index 7ba12ed..4b420a4 100644 --- a/lib/state/app/modules/settings/bloc/settings_cubit.dart +++ b/lib/state/app/modules/settings/bloc/settings_cubit.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:developer'; +import 'package:collection/collection.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart'; import '../../../../../storage/settings.dart'; @@ -10,6 +11,8 @@ import '../../../../../view/pages/settings/data/default_settings.dart'; class SettingsCubit extends HydratedCubit { static const _debounceTag = 'settings_persist'; bool _emitScheduled = false; + Map? _lastEmittedJson; + Timer? _silentSave; SettingsCubit() : super(DefaultSettings.get()); @@ -34,21 +37,53 @@ class SettingsCubit extends HydratedCubit { return state; } + /// Persists in-place mutations without notifying listeners. For high-frequency + /// writes nobody renders live (chat drafts per keystroke): a regular write + /// would rebuild the app root and every settings watcher on each change. + void saveSilently() { + _silentSave?.cancel(); + _silentSave = Timer(const Duration(milliseconds: 800), flushSilentSave); + } + + /// Writes a pending [saveSilently] right away (e.g. when the app pauses). + void flushSilentSave() { + if (_silentSave == null) return; + _silentSave!.cancel(); + _silentSave = null; + HydratedBloc.storage.write(storageToken, state.toJson()); + } + void _emitFreshInstance() { try { - emit(Settings.fromJson(state.toJson())); + final json = state.toJson(); + // The debounced emit usually follows the microtask emit with identical + // content; skip it instead of rebuilding every listener a second time. + if (const DeepCollectionEquality().equals(json, _lastEmittedJson)) { + return; + } + _lastEmittedJson = json; + emit(Settings.fromJson(json)); } catch (e) { log('Failed to refresh settings state: $e'); } } Future reset() async { + _silentSave?.cancel(); + _silentSave = null; + _lastEmittedJson = null; emit(DefaultSettings.get()); } // Modules missing from a stale persisted moduleOrder are handled at read // time by AppModule.effectiveModuleOrder (inserted at their default // position) — no healing on hydration needed. + @override + Future close() { + flushSilentSave(); + return super.close(); + } + @override Settings fromJson(Map json) { try { diff --git a/lib/state/app/modules/ticker/bloc/ticker_page_bloc.dart b/lib/state/app/modules/ticker/bloc/ticker_page_bloc.dart index 48ae302..7c9d7a0 100644 --- a/lib/state/app/modules/ticker/bloc/ticker_page_bloc.dart +++ b/lib/state/app/modules/ticker/bloc/ticker_page_bloc.dart @@ -1,3 +1,8 @@ +import 'dart:async'; + +import 'package:hydrated_bloc/hydrated_bloc.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + import '../../../../../api/errors/ticker_content_unavailable_exception.dart'; import '../../../../../api/marianumconnect/queries/get_ticker_page/get_ticker_page_response.dart'; import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart'; @@ -17,7 +22,35 @@ class TickerPageBloc > { final String slug; - TickerPageBloc(this.slug); + TickerPageBloc(this.slug) { + unawaited(_rememberSlug()); + } + + static const _recentSlugsKey = 'tickerPageRecentSlugs'; + static const _keepPages = 50; + + /// Every page ever opened used to stay in the hydrated box forever — and + /// the whole box is decoded before the first frame on each cold start. + /// The most recently opened pages (far more than a normal reader revisits) + /// keep their offline copy; only long-forgotten ones are dropped. + Future _rememberSlug() async { + try { + final prefs = await SharedPreferences.getInstance(); + final recent = prefs.getStringList(_recentSlugsKey) ?? []; + recent + ..remove(slug) + ..insert(0, slug); + for (final evicted in recent.skip(_keepPages)) { + await HydratedBloc.storage.delete('$storagePrefix$evicted'); + } + await prefs.setStringList( + _recentSlugsKey, + recent.take(_keepPages).toList(), + ); + } on Object { + // Best effort: a failed cleanup only leaves an extra offline copy. + } + } @override String get id => slug; diff --git a/lib/state/app/modules/timetable/bloc/timetable_bloc.dart b/lib/state/app/modules/timetable/bloc/timetable_bloc.dart index 60380a2..5f081d5 100644 --- a/lib/state/app/modules/timetable/bloc/timetable_bloc.dart +++ b/lib/state/app/modules/timetable/bloc/timetable_bloc.dart @@ -133,11 +133,7 @@ class TimetableBloc Future _refreshSubjects() async { final subjects = await repo.data.getSubjects(renew: true); - add( - DataGathered( - (s) => s.copyWith(subjects: subjects, dataVersion: s.dataVersion + 1), - ), - ); + add(DataGathered((s) => s.withReferenceData(subjects: subjects))); } Future _loadCurrentWeek( @@ -177,12 +173,11 @@ class TimetableBloc add( Emit( - (s) => s.copyWith( + (s) => s.withReferenceData( rooms: rooms, subjects: subjects, schoolHolidays: schoolHolidays, schoolyear: schoolyear, - dataVersion: s.dataVersion + 1, ), ), ); @@ -192,11 +187,7 @@ class TimetableBloc try { final timegrid = await repo.data.getTimegrid(renew: renew); - add( - Emit( - (s) => s.copyWith(timegrid: timegrid, dataVersion: s.dataVersion + 1), - ), - ); + add(Emit((s) => s.withReferenceData(timegrid: timegrid))); } catch (_) { // Timegrid load failure falls back to a hardcoded schedule in the UI layer. } @@ -212,12 +203,7 @@ class TimetableBloc renew: renew, onError: onError, ); - add( - Emit( - (s) => - s.copyWith(customEvents: events, dataVersion: s.dataVersion + 1), - ), - ); + add(Emit((s) => s.withReferenceData(customEvents: events))); } catch (e) { onError?.call(e); } @@ -225,11 +211,7 @@ class TimetableBloc Future _refreshCustomEvents() async { final events = await repo.data.getCustomEvents(renew: true); - add( - DataGathered( - (s) => s.copyWith(customEvents: events, dataVersion: s.dataVersion + 1), - ), - ); + add(DataGathered((s) => s.withReferenceData(customEvents: events))); } void _prefetchAdjacentWeeks(DateTime start, DateTime end) { diff --git a/lib/state/app/modules/timetable/bloc/timetable_state.dart b/lib/state/app/modules/timetable/bloc/timetable_state.dart index 12117a5..a6f6641 100644 --- a/lib/state/app/modules/timetable/bloc/timetable_state.dart +++ b/lib/state/app/modules/timetable/bloc/timetable_state.dart @@ -7,6 +7,7 @@ import '../../../../../api/marianumconnect/queries/timetable_get_subjects/timeta import '../../../../../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart'; import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; import '../../../../../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart'; +import '../../../../../utils/json_equality.dart'; import 'week_cache.dart'; part 'timetable_state.freezed.dart'; @@ -58,6 +59,45 @@ abstract class TimetableState with _$TimetableState { return copyWith(weekCache: updated, dataVersion: dataVersion + 1); } + /// Applies freshly loaded reference data. Values whose content equals the + /// current one keep the old instance, and when nothing changed at all this + /// returns `this` — every `dataVersion` bump and new identity makes the + /// calendar rebuild all appointments and break regions. + TimetableState withReferenceData({ + TimetableGetRoomsResponse? rooms, + TimetableGetSubjectsResponse? subjects, + TimetableGetHolidaysResponse? schoolHolidays, + TimetableGetSchoolyearResponse? schoolyear, + TimetableGetTimegridResponse? timegrid, + GetCustomTimetableEventResponse? customEvents, + }) { + T? changed(T? next, T? current) => + next == null || sameJson(next, current) ? null : next; + final newRooms = changed(rooms, this.rooms); + final newSubjects = changed(subjects, this.subjects); + final newHolidays = changed(schoolHolidays, this.schoolHolidays); + final newSchoolyear = changed(schoolyear, this.schoolyear); + final newTimegrid = changed(timegrid, this.timegrid); + final newCustomEvents = changed(customEvents, this.customEvents); + if (newRooms == null && + newSubjects == null && + newHolidays == null && + newSchoolyear == null && + newTimegrid == null && + newCustomEvents == null) { + return this; + } + return copyWith( + rooms: newRooms ?? this.rooms, + subjects: newSubjects ?? this.subjects, + schoolHolidays: newHolidays ?? this.schoolHolidays, + schoolyear: newSchoolyear ?? this.schoolyear, + timegrid: newTimegrid ?? this.timegrid, + customEvents: newCustomEvents ?? this.customEvents, + dataVersion: dataVersion + 1, + ); + } + bool get hasReferenceData => rooms != null && subjects != null && diff --git a/lib/state/app/modules/timetable/bloc/week_cache.dart b/lib/state/app/modules/timetable/bloc/week_cache.dart index 5281912..c1d3c9e 100644 --- a/lib/state/app/modules/timetable/bloc/week_cache.dart +++ b/lib/state/app/modules/timetable/bloc/week_cache.dart @@ -1,13 +1,12 @@ -import 'package:collection/collection.dart'; - import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; import '../../../../../extensions/date_time.dart'; +import '../../../../../utils/json_equality.dart'; /// Weeks kept around the viewed week and around today's week. Everything /// further away is dropped: every state emit re-serializes the whole cache for /// HydratedBloc and the calendar rebuilds its appointments from all of it, so /// an unbounded cache makes week swipes slower the longer the app is used. -const int kWeekCacheRadius = 4; +const int kWeekCacheRadius = 8; /// Returns the cache with [week] stored under [weekStart], pruned to the /// weeks near [viewedWeekStart] or [now]. Returns null when the stored week @@ -21,10 +20,7 @@ Map? mergeWeekIntoCache( }) { final key = weekStart.weekKey(); final existing = cache[key]; - if (existing != null && - const DeepCollectionEquality().equals(existing.toJson(), week.toJson())) { - return null; - } + if (sameJson(existing, week)) return null; final viewedMonday = viewedWeekStart.mondayOfWeek; final todayMonday = now.mondayOfWeek; diff --git a/lib/utils/json_equality.dart b/lib/utils/json_equality.dart new file mode 100644 index 0000000..295d7e6 --- /dev/null +++ b/lib/utils/json_equality.dart @@ -0,0 +1,7 @@ +import 'dart:convert'; + +/// Content equality for API models that define no `==` but serialise to JSON. +/// Comparing the encoded strings is cheaper than a deep map walk and treats +/// both sides identically (same `toJson`, same field order). +bool sameJson(Object? a, Object? b) => + a != null && b != null && jsonEncode(a) == jsonEncode(b); diff --git a/lib/utils/recurrence_occurrences.dart b/lib/utils/recurrence_occurrences.dart new file mode 100644 index 0000000..376424e --- /dev/null +++ b/lib/utils/recurrence_occurrences.dart @@ -0,0 +1,75 @@ +import 'package:rrule/rrule.dart'; + +/// Memoised RRULE expansion. +/// +/// `RecurrenceRule.getInstances` always starts at the series anchor, so asking +/// for one week of a daily event created two years ago walks ~750 occurrences +/// (tens of ms each time on older phones) — and the calendar asks once per +/// week and event, the home widget twice per publish. Here each series keeps +/// its parsed rule, the occurrences found so far and the live iterator, so +/// later queries only extend the expansion instead of restarting it. +class RecurrenceOccurrences { + RecurrenceOccurrences._(); + + static const int _maxSeries = 64; + static final Map<(String, DateTime), _Series> _cache = {}; + + /// UTC occurrences of [rule] anchored at [anchorUtc] in + /// `[fromUtc, toUtc)`, in ascending order. Throws like + /// [RecurrenceRule.fromString] for an invalid rule. + static List between( + String rule, + DateTime anchorUtc, + DateTime fromUtc, + DateTime toUtc, + ) { + final key = (rule, anchorUtc); + var series = _cache.remove(key); + series ??= _Series(RecurrenceRule.fromString(rule), anchorUtc); + // Re-insert to keep the map in least-recently-used order. + _cache[key] = series; + if (_cache.length > _maxSeries) _cache.remove(_cache.keys.first); + return series.between(fromUtc, toUtc); + } + + /// Drops all memoised series (tests). + static void clear() => _cache.clear(); +} + +class _Series { + final Iterator _iterator; + final List _found = []; + bool _exhausted = false; + + _Series(RecurrenceRule rule, DateTime anchorUtc) + : _iterator = rule.getInstances(start: anchorUtc).iterator; + + List between(DateTime fromUtc, DateTime toUtc) { + while (!_exhausted && (_found.isEmpty || _found.last.isBefore(toUtc))) { + if (_iterator.moveNext()) { + _found.add(_iterator.current); + } else { + _exhausted = true; + } + } + final start = _lowerBound(fromUtc); + return [ + for (var i = start; i < _found.length && _found[i].isBefore(toUtc); i++) + _found[i], + ]; + } + + int _lowerBound(DateTime value) { + var low = 0; + var high = _found.length; + while (low < high) { + final mid = (low + high) >> 1; + if (_found[mid].isBefore(value)) { + low = mid + 1; + } else { + high = mid; + } + } + return low; + } +} diff --git a/lib/utils/screen_bound_image.dart b/lib/utils/screen_bound_image.dart new file mode 100644 index 0000000..dd97b36 --- /dev/null +++ b/lib/utils/screen_bound_image.dart @@ -0,0 +1,25 @@ +import 'package:flutter/widgets.dart'; + +/// Wraps [provider] so it decodes at most [scale] × the screen's longest +/// physical side (capped at [maxPx]). Camera-sized photos otherwise decode at +/// full resolution — 50–200 MB each — which gets the app killed on +/// low-memory devices. +ImageProvider screenBoundImage( + BuildContext context, + ImageProvider provider, { + double scale = 1, + int maxPx = 4096, +}) { + final bound = + (MediaQuery.sizeOf(context).longestSide * + MediaQuery.devicePixelRatioOf(context) * + scale) + .round() + .clamp(1, maxPx); + return ResizeImage( + provider, + width: bound, + height: bound, + policy: ResizeImagePolicy.fit, + ); +} diff --git a/lib/utils/session_single_flight.dart b/lib/utils/session_single_flight.dart new file mode 100644 index 0000000..5ee8d2e --- /dev/null +++ b/lib/utils/session_single_flight.dart @@ -0,0 +1,21 @@ +import '../model/account_data.dart'; + +/// Joins concurrent calls into the one already running for the same account +/// session. A run left over from a signed-out session never blocks the next +/// account's first call. +class SessionSingleFlight { + Future? _running; + int? _epoch; + + Future run(Future Function() action) { + final epoch = AccountData().sessionEpoch; + final running = _running; + if (running != null && _epoch == epoch) return running; + _epoch = epoch; + late final Future current; + current = action().whenComplete(() { + if (identical(_running, current)) _running = null; + }); + return _running = current; + } +} diff --git a/lib/view/pages/files/data/sort_options.dart b/lib/view/pages/files/data/sort_options.dart index 3532560..e9d263d 100644 --- a/lib/view/pages/files/data/sort_options.dart +++ b/lib/view/pages/files/data/sort_options.dart @@ -21,7 +21,7 @@ class SortOptions { SortOption.name: BetterSortOption( displayName: 'Name', icon: Icons.sort_by_alpha_outlined, - compare: (a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()), + compare: (a, b) => a.lowerName.compareTo(b.lowerName), ), SortOption.date: BetterSortOption( displayName: 'Datum', diff --git a/lib/view/pages/files/files.dart b/lib/view/pages/files/files.dart index 649427b..ffcacf4 100644 --- a/lib/view/pages/files/files.dart +++ b/lib/view/pages/files/files.dart @@ -4,6 +4,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; +import '../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; +import '../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart'; import '../../../state/app/infrastructure/loadable_state/loadable_state.dart'; import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart'; @@ -47,6 +49,22 @@ class _FilesViewState extends State<_FilesView> { late bool currentSortDirection; late final StreamSubscription _invalidationSub; + // The list builder also runs for loading flips and parent rebuilds; only a + // new listing or a changed sort needs another sort pass. + Object? _sortedKey; + List _sortedFiles = const []; + + List _sorted(ListFilesResponse listing, bool foldersToTop) { + final key = (listing, currentSort, currentSortDirection, foldersToTop); + if (key == _sortedKey) return _sortedFiles; + _sortedKey = key; + return _sortedFiles = listing.sortBy( + sortOption: currentSort, + foldersToTop: foldersToTop, + reversed: currentSortDirection, + ); + } + // Cache key in FilesBloc's pathString format: '/' for root, otherwise // segments joined without leading/trailing slash. String get _myPathString => widget.path.isEmpty ? '/' : widget.path.join('/'); @@ -98,6 +116,11 @@ class _FilesViewState extends State<_FilesView> { @override Widget build(BuildContext context) { final bloc = context.read(); + // Selected here, not in the LoadableStateConsumer child: that closure runs + // during the consumer's build, where this context may not subscribe. + final foldersToTop = context.select( + (SettingsCubit c) => c.state.fileSettings.sortFoldersToTop, + ); return Scaffold( appBar: AppBar( title: Text(widget.path.isNotEmpty ? widget.path.last : 'Dateien'), @@ -153,15 +176,7 @@ class _FilesViewState extends State<_FilesView> { text: 'Der Ordner ist leer', ); } - final files = listing.sortBy( - sortOption: currentSort, - foldersToTop: context - .watch() - .val() - .fileSettings - .sortFoldersToTop, - reversed: currentSortDirection, - ); + final files = _sorted(listing, foldersToTop); return ListView.builder( padding: EdgeInsets.zero, itemCount: files.length, diff --git a/lib/view/pages/files/files_upload_dialog.dart b/lib/view/pages/files/files_upload_dialog.dart index bec671f..0288c78 100644 --- a/lib/view/pages/files/files_upload_dialog.dart +++ b/lib/view/pages/files/files_upload_dialog.dart @@ -191,18 +191,23 @@ class _FilesUploadDialogState extends State { } final HttpClientResponse uploadTask; + final fileIndex = _uploadableFiles.indexOf(file); + var lastPercent = -1; try { uploadTask = await webdavClient.putFile( File(filePath), fileStat, PathUri.parse(fullRemotePath), onProgress: (progress) { + // Called per 64 KB chunk — thousands of times for a video. Only + // rebuild when the visible percentage actually moves. + final percent = (progress * 100).floor(); + if (!mounted || percent == lastPercent) return; + lastPercent = percent; setState(() { file._uploadProgress = progress; _overallProgressValue = - ((progress + _uploadableFiles.indexOf(file)) / - _uploadableFiles.length) - .toDouble(); + ((progress + fileIndex) / _uploadableFiles.length).toDouble(); }); }, ); @@ -246,7 +251,12 @@ class _FilesUploadDialogState extends State { itemCount: _uploadableFiles.length, itemBuilder: (context, index) { final currentFile = _uploadableFiles[index]; - currentFile.fileNameController.text = currentFile.fileName; + // Only sync when it differs: assigning text resets selection + // and notifies the field on every (progress) rebuild. + if (currentFile.fileNameController.text != + currentFile.fileName) { + currentFile.fileNameController.text = currentFile.fileName; + } return ListTile( title: TextField( readOnly: _isUploading, diff --git a/lib/view/pages/files/search/files_search_controller.dart b/lib/view/pages/files/search/files_search_controller.dart index 21e0399..1086a20 100644 --- a/lib/view/pages/files/search/files_search_controller.dart +++ b/lib/view/pages/files/search/files_search_controller.dart @@ -26,6 +26,14 @@ class FilesSearchController extends ChangeNotifier { Object? _serverError; int _serverEpoch = 0; bool _disposed = false; + Future>? _localIndex; + + Future> _searchLocal(List pathScope) async => + searchLocalCacheIndex( + await (_localIndex ??= loadLocalCacheIndex()), + _query, + pathScope: pathScope, + ); /// Guards against the race where the search delegate is closed (and the /// controller disposed) while a debounced cache scan or server call is @@ -80,7 +88,7 @@ class FilesSearchController extends ChangeNotifier { _serverError = null; _safeNotify(); - final cacheHits = await searchLocalCaches(_query, pathScope: _pathScope); + final cacheHits = await _searchLocal(_pathScope); if (epoch != _serverEpoch) return; _cacheResults = cacheHits; _safeNotify(); @@ -101,7 +109,7 @@ class FilesSearchController extends ChangeNotifier { _serverError = null; _safeNotify(); - final cacheHits = await searchLocalCaches(_query); + final cacheHits = await _searchLocal(const []); if (epoch != _serverEpoch) return; _cacheResults = cacheHits; _safeNotify(); diff --git a/lib/view/pages/files/search/files_search_results.dart b/lib/view/pages/files/search/files_search_results.dart index ff9b611..69db680 100644 --- a/lib/view/pages/files/search/files_search_results.dart +++ b/lib/view/pages/files/search/files_search_results.dart @@ -104,32 +104,43 @@ class FilesSearchResults extends StatelessWidget { Widget _resultList(BuildContext context, List combined) { final groups = _groupByParent(combined); final orderedKeys = groups.keys.toList()..sort(); - final items = []; - for (final folder in orderedKeys) { - final segments = _segmentsOf(folder); - items.add( - _FolderHeader( - folder: folder, - onOpen: () { - onResultTap?.call(); - AppRoutes.openFolder(context, segments); - }, - ), - ); - for (final file in groups[folder]!) { - items.add( - FileElement( - file, - segments, - controller.retry, - highlight: controller.query, - ), + // Flat (folder header | file) rows built lazily: results can run into + // the hundreds and arrive in several batches per query. + final rows = <(String, CacheableFile?)>[ + for (final folder in orderedKeys) ...[ + (folder, null), + for (final file in groups[folder]!) (folder, file), + ], + ]; + return ListView.builder( + padding: EdgeInsets.zero, + itemCount: rows.length, + itemBuilder: (context, index) { + final (folder, file) = rows[index]; + final segments = _segmentsOf(folder); + if (file == null) { + return _FolderHeader( + key: ValueKey('folder:$folder'), + folder: folder, + onOpen: () { + onResultTap?.call(); + AppRoutes.openFolder(context, segments); + }, + ); + } + return FileElement( + file, + segments, + controller.retry, + key: ValueKey('file:${file.path}'), + highlight: controller.query, ); - } - } - return ListView(padding: EdgeInsets.zero, children: items); + }, + ); } + static final RegExp _edgeSlashes = RegExp(r'^/+|/+$'); + Map> _groupByParent(List files) { final map = >{}; for (final file in files) { @@ -139,7 +150,7 @@ class FilesSearchResults extends StatelessWidget { } String _parentOf(CacheableFile file) { - final stripped = file.path.replaceAll(RegExp(r'^/+|/+$'), ''); + final stripped = file.path.replaceAll(_edgeSlashes, ''); final segments = stripped.split('/'); if (segments.length <= 1) return '/'; segments.removeLast(); @@ -147,7 +158,7 @@ class FilesSearchResults extends StatelessWidget { } List _segmentsOf(String folder) { - final stripped = folder.replaceAll(RegExp(r'^/+|/+$'), ''); + final stripped = folder.replaceAll(_edgeSlashes, ''); if (stripped.isEmpty) return const []; return stripped.split('/'); } @@ -156,7 +167,11 @@ class FilesSearchResults extends StatelessWidget { class _FolderHeader extends StatelessWidget { final String folder; final VoidCallback onOpen; - const _FolderHeader({required this.folder, required this.onOpen}); + const _FolderHeader({ + required this.folder, + required this.onOpen, + super.key, + }); @override Widget build(BuildContext context) { diff --git a/lib/view/pages/files/search/local_cache_search.dart b/lib/view/pages/files/search/local_cache_search.dart index 45a14e4..ea090da 100644 --- a/lib/view/pages/files/search/local_cache_search.dart +++ b/lib/view/pages/files/search/local_cache_search.dart @@ -1,49 +1,29 @@ import 'dart:convert'; -import 'package:localstore/localstore.dart'; +import 'package:flutter/foundation.dart'; +import '../../../../api/cache_store.dart'; import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; import '../../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart'; -import '../../../../api/request_cache.dart'; /// Document key prefix used by `ListFilesCache._documentId`. const String _folderCachePrefix = 'wd-folder-'; -/// Scans every cached folder listing in Localstore and returns files/folders -/// whose name contains [query] (case-insensitive). -/// -/// [pathScope] restricts results to entries whose WebDAV path starts with -/// the given folder. Pass an empty list (or null) to search globally. -/// -/// [docs] is an injection seam for tests — production callers leave it null -/// so the helper reads from the real Localstore. -Future> searchLocalCaches( - String query, { - List? pathScope, - Map? docs, -}) async { - final trimmed = query.trim(); - if (trimmed.isEmpty) return const []; - final needle = trimmed.toLowerCase(); - final scopePrefix = pathScope == null || pathScope.isEmpty - ? '' - : '${pathScope.join('/')}/'; - - final raw = - docs ?? - await Localstore.instance.collection(RequestCache.collection).get(); - if (raw == null || raw.isEmpty) return const []; - - final results = {}; - for (final entry in raw.entries) { - final docKey = entry.key.split('/').last; - if (!docKey.startsWith(_folderCachePrefix)) continue; - - final value = entry.value; - if (value is! Map) continue; - final json = value['json']; - if (json is! String) continue; +/// Every file and folder from the cached folder listings, deduplicated by +/// path. Built once per search session: reading and parsing all listings on +/// each keystroke used to stall typing. +Future> loadLocalCacheIndex() async { + final entries = await CacheStore.instance.readAll(prefix: _folderCachePrefix); + if (entries.isEmpty) return const []; + final payloads = [for (final entry in entries.values) entry.json]; + return compute(buildLocalCacheIndex, payloads); +} +/// Parses cached `ListFilesResponse` payloads into a deduplicated file list. +/// Unparsable payloads are skipped. +List buildLocalCacheIndex(List payloads) { + final byPath = {}; + for (final json in payloads) { final ListFilesResponse listing; try { listing = ListFilesResponse.fromJson( @@ -52,14 +32,32 @@ Future> searchLocalCaches( } on Object { continue; } - for (final file in listing.files) { - if (!file.name.toLowerCase().contains(needle)) continue; - if (scopePrefix.isNotEmpty && !file.path.startsWith(scopePrefix)) { - continue; - } - results[file.path] ??= file; + byPath[file.path] ??= file; } } - return results.values.toList(); + return byPath.values.toList(); +} + +/// Files in [index] whose name contains [query] (case-insensitive). +/// +/// [pathScope] restricts results to entries whose WebDAV path starts with +/// the given folder. Pass an empty list (or null) to search globally. +List searchLocalCacheIndex( + List index, + String query, { + List? pathScope, +}) { + final trimmed = query.trim(); + if (trimmed.isEmpty) return const []; + final needle = trimmed.toLowerCase(); + final scopePrefix = pathScope == null || pathScope.isEmpty + ? '' + : '${pathScope.join('/')}/'; + return [ + for (final file in index) + if (file.lowerName.contains(needle) && + (scopePrefix.isEmpty || file.path.startsWith(scopePrefix))) + file, + ]; } diff --git a/lib/view/pages/foreign_timetable/element_picker_page.dart b/lib/view/pages/foreign_timetable/element_picker_page.dart index 61db705..1d226de 100644 --- a/lib/view/pages/foreign_timetable/element_picker_page.dart +++ b/lib/view/pages/foreign_timetable/element_picker_page.dart @@ -7,6 +7,7 @@ import '../../../api/marianumconnect/queries/timetable_get_element_week/timetabl import '../../../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms.dart'; import '../../../api/marianumconnect/queries/timetable_get_students/timetable_get_students.dart'; import '../../../api/marianumconnect/queries/timetable_get_teachers/timetable_get_teachers.dart'; +import '../../../model/account_data.dart'; import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../storage/timetable_favorites_settings.dart'; import '../../../utils/haptics.dart'; @@ -31,7 +32,14 @@ class _ElementPickerPageState extends State { // One in-flight/resolved future per type so switching tabs (or rebuilds) // never re-fetches a list that's already loaded. - final Map>> _futures = {}; + // Session-wide (per account): the student list alone has 1000+ entries and + // used to be downloaded again every time the picker opened. + static final Map< + TimetableElementType, + ({int epoch, DateTime at, Future> items}) + > + _futures = {}; + static const Duration _listMaxAge = Duration(minutes: 15); // Memoised combined future for the "Alle" tab; rebuilt on retry. Future>? _allFuture; @@ -47,8 +55,25 @@ class _ElementPickerPageState extends State { return _allFuture ??= _loadAll(); } - Future> _loadFor(TimetableElementType type) => - _futures.putIfAbsent(type, () => _fetch(type)); + Future> _loadFor(TimetableElementType type) { + final epoch = AccountData().sessionEpoch; + final cached = _futures[type]; + if (cached != null && + cached.epoch == epoch && + DateTime.now().difference(cached.at) < _listMaxAge) { + return cached.items; + } + final items = _fetch(type); + _futures[type] = (epoch: epoch, at: DateTime.now(), items: items); + // A failed load must not stick for the rest of the session. + items.then( + (_) {}, + onError: (Object _) { + if (identical(_futures[type]?.items, items)) _futures.remove(type); + }, + ); + return items; + } Future> _loadAll() async { final lists = await Future.wait(TimetableElementType.values.map(_loadFor)); @@ -117,20 +142,16 @@ class _ElementPickerPageState extends State { Haptics.selection(); // Hand the selection back to the timetable view, which renders the foreign // plan inline. We do not navigate to a new page. - Navigator.of(context).pop(( - type: item.type, - id: item.id, - label: item.primary, - )); + Navigator.of( + context, + ).pop((type: item.type, id: item.id, label: item.primary)); } void _openFavorite(FavoriteTimetableElement favorite) { Haptics.selection(); - Navigator.of(context).pop(( - type: favorite.type, - id: favorite.id, - label: favorite.label, - )); + Navigator.of( + context, + ).pop((type: favorite.type, id: favorite.id, label: favorite.label)); } void _toggleFavorite(TimetableElementType type, int id, String label) { @@ -325,9 +346,7 @@ class _ElementPickerPageState extends State { return ListTile( leading: Icon(_iconFor(item.type)), title: Text(item.primary), - subtitle: subtitleParts.isEmpty - ? null - : Text(subtitleParts.join(' · ')), + subtitle: subtitleParts.isEmpty ? null : Text(subtitleParts.join(' · ')), trailing: IconButton( icon: Icon(isFavorite ? Icons.star : Icons.star_border), tooltip: isFavorite ? 'Favorit entfernen' : 'Als Favorit markieren', diff --git a/lib/view/pages/settings/chat_background_settings_page.dart b/lib/view/pages/settings/chat_background_settings_page.dart index 0012913..9bc47b0 100644 --- a/lib/view/pages/settings/chat_background_settings_page.dart +++ b/lib/view/pages/settings/chat_background_settings_page.dart @@ -214,8 +214,11 @@ class ChatBackgroundSettingsPage extends StatelessWidget { final file = File(AppPaths.chatBackgroundImage); await file.writeAsBytes(bytes); // Same filename across replacements → the decoded image is cached under - // an identical key. Evict so the new bytes actually show. - await FileImage(file).evict(); + // an identical key (for "cover" also wrapped in a viewport-sized + // ResizeImage). Clearing the cache is fine for this rare action. + PaintingBinding.instance.imageCache + ..clear() + ..clearLiveImages(); final cs = settings.val(write: true).chatBackgroundSettings; cs.imageVersion++; cs.type = ChatBackgroundType.image; diff --git a/lib/view/pages/settings/sections/dev_tools_section.dart b/lib/view/pages/settings/sections/dev_tools_section.dart index 2d70847..ac8963d 100644 --- a/lib/view/pages/settings/sections/dev_tools_section.dart +++ b/lib/view/pages/settings/sections/dev_tools_section.dart @@ -3,13 +3,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart'; +import '../../../../api/cache_store.dart'; import '../../../../routing/app_routes.dart'; import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../../storage/dev_tools_settings.dart'; import '../../../../storage/settings.dart' as model; import '../../../../widget/centered_leading.dart'; import '../../../../widget/confirm_dialog.dart'; -import '../../../../widget/debug/cache_view.dart'; import '../../../../widget/debug/json_viewer.dart'; import '../../../../widget/details_bottom_sheet.dart'; import '../widgets/endpoint_picker.dart'; @@ -24,6 +24,9 @@ class DevToolsSection extends StatefulWidget { } class _DevToolsSectionState extends State { + // Kept across rebuilds: the size walk lists the whole cache directory. + Future? _cacheSize; + @override Widget build(BuildContext context) => Column( children: [ @@ -153,7 +156,7 @@ class _DevToolsSectionState extends State { leading: const CenteredLeading(Icon(Icons.data_object)), title: const Text('Cache-storage JSON dump'), subtitle: FutureBuilder( - future: const CacheView().totalSize(), + future: _cacheSize ??= CacheStore.instance.totalSize(), builder: (context, snapshot) => Text( "etwa ${snapshot.hasError ? "?" @@ -169,8 +172,9 @@ class _DevToolsSectionState extends State { content: 'Alle cache Einträge werden gelöscht. Der Cache wird bei Nutzung der App automatisch erneut aufgebaut', confirmButton: 'Unwiederruflich löschen', - onConfirm: () => - const CacheView().clear().then((value) => setState(() {})), + onConfirm: () => CacheStore.instance.clear().then( + (value) => setState(() => _cacheSize = null), + ), ).asDialog(context); }, trailing: const Icon(Icons.arrow_right), diff --git a/lib/view/pages/talk/chat_list.dart b/lib/view/pages/talk/chat_list.dart index 18051e4..d0923e2 100644 --- a/lib/view/pages/talk/chat_list.dart +++ b/lib/view/pages/talk/chat_list.dart @@ -75,6 +75,17 @@ class _ChatListViewState extends State<_ChatListView> { @override Widget build(BuildContext context) { final bloc = context.read(); + // Selected here, not in the LoadableStateConsumer child: that closure runs + // during the consumer's build, where this context may not subscribe. + // Draft keys are part of the selection so the draft marker follows; the + // draft text itself is saved without notifying. + final (favoritesToTop, unreadToTop, _) = context.select( + (SettingsCubit c) => ( + c.state.talkSettings.sortFavoritesToTop, + c.state.talkSettings.sortUnreadToTop, + c.state.talkSettings.drafts.keys.join('|'), + ), + ); return SplitView.material( placeholder: const SplitViewPlaceholder(), breakpoint: 1000, @@ -129,13 +140,9 @@ class _ChatListViewState extends State<_ChatListView> { final rooms = state.rooms; if (rooms == null) return const SizedBox.shrink(); - final talkSettings = context - .watch() - .val() - .talkSettings; final sorted = rooms.sortBy( - favoritesToTop: talkSettings.sortFavoritesToTop, - unreadToTop: talkSettings.sortUnreadToTop, + favoritesToTop: favoritesToTop, + unreadToTop: unreadToTop, ); if (sorted.isEmpty) { @@ -145,23 +152,25 @@ class _ChatListViewState extends State<_ChatListView> { ); } - return ListView( + final drafts = _settings.val().talkSettings.drafts; + final indexByToken = { + for (var i = 0; i < sorted.length; i++) sorted[i].token: i, + }; + return ListView.builder( padding: EdgeInsets.zero, - children: sorted.map((room) { - final hasDraft = _settings - .val() - .talkSettings - .drafts - .containsKey(room.token); - // Stable key keeps element identity across re-sorts so the - // inner UserAvatar reuses its cached bytes instead of - // flashing on every list update. + itemCount: sorted.length, + // Keeps each tile's state (cached avatar bytes) with its room + // when a re-sort moves it to another index. + findChildIndexCallback: (key) => + indexByToken[(key as ValueKey).value], + itemBuilder: (context, index) { + final room = sorted[index]; return ChatTile( key: ValueKey(room.token), data: room, - hasDraft: hasDraft, + hasDraft: drafts.containsKey(room.token), ); - }).toList(), + }, ); }, ), diff --git a/lib/view/pages/talk/chat_view.dart b/lib/view/pages/talk/chat_view.dart index f3a1ba9..6005875 100644 --- a/lib/view/pages/talk/chat_view.dart +++ b/lib/view/pages/talk/chat_view.dart @@ -14,6 +14,7 @@ import '../../../state/app/infrastructure/loadable_state/view/loadable_state_con import '../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../state/app/modules/chat/bloc/chat_state.dart'; import '../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; +import '../../../utils/debouncer.dart'; import '../../../widget/chat_background.dart'; import '../../../widget/clickable_app_bar.dart'; import '../../../widget/user_avatar.dart'; @@ -118,6 +119,7 @@ class _ChatViewState extends State with RouteAware { _markAsReadFinal(); _chatBlocRef?.leaveChat(widget.room.token); _searchTextController.dispose(); + Debouncer.cancel(_searchDebounceTag); super.dispose(); } @@ -150,6 +152,45 @@ class _ChatViewState extends State with RouteAware { context.read().setToken(widget.room.token); } + void _refetch({bool renew = false}) => _refresh(); + + // The built rows only depend on the chat data and the search state; the list + // itself rebuilds far more often (loading flips, keyboard, parent rebuilds), + // and re-sorting plus re-creating every bubble each time is wasted work. + Object? _itemsKey; + List _items = const []; + + List _itemsFor(ChatState state) { + final key = ( + state.chatResponse, + state.isLoadingOlder, + state.hasMoreOld, + _searchActive, + _searchQuery, + _activeMatchIndex, + widget.room, + ); + if (key == _itemsKey) return _items; + _itemsKey = key; + final items = _buildMessages(state.chatResponse!).reversed.toList(); + // reverse:true renders index 0 at the bottom, so the top marker + // (spinner / start-of-chat) goes at the end. + if (state.isLoadingOlder) { + items.add(const _LoadingOlderIndicator()); + } else if (!state.hasMoreOld) { + items.add( + ChatBubble( + key: const ValueKey('chat-start'), + isSender: false, + bubbleData: GetChatResponseObject.getTextDummy('Anfang des Chats'), + chatData: widget.room, + refetch: _refetch, + ), + ); + } + return _items = items; + } + void _enterSearchMode() { setState(() { _searchActive = true; @@ -163,6 +204,7 @@ class _ChatViewState extends State with RouteAware { } void _exitSearchMode() { + Debouncer.cancel(_searchDebounceTag); setState(() { _searchActive = false; _searchQuery = ''; @@ -175,7 +217,22 @@ class _ChatViewState extends State with RouteAware { }); } + late final String _searchDebounceTag = + 'chat-search-${identityHashCode(this)}'; + + /// Matching re-scans the whole history and rebuilds every row, so it runs + /// once typing pauses instead of per keystroke. void _onSearchChanged(String q) { + Debouncer.debounce( + _searchDebounceTag, + const Duration(milliseconds: 200), + () { + if (mounted) _applySearch(q); + }, + ); + } + + void _applySearch(String q) { final chatResponse = context.read().state.data?.chatResponse; setState(() { _searchQuery = q; @@ -268,9 +325,13 @@ class _ChatViewState extends State with RouteAware { messages.add( ChatBubble( isSender: false, + key: ValueKey( + 'day-${elementDate.year}-${elementDate.month}-' + '${elementDate.day}', + ), bubbleData: GetChatResponseObject.getDateDummy(element.timestamp), chatData: widget.room, - refetch: ({bool renew = false}) => _refresh(), + refetch: _refetch, ), ); } @@ -286,6 +347,7 @@ class _ChatViewState extends State with RouteAware { messages.add( ChatBubble( + key: ValueKey(element.id), isSender: element.actorId == widget.selfId && (element.messageType == @@ -294,7 +356,7 @@ class _ChatViewState extends State with RouteAware { GetRoomResponseObjectMessageType.deletedComment), bubbleData: element, chatData: widget.room, - refetch: ({bool renew = false}) => _refresh(), + refetch: _refetch, isRead: element.id <= commonRead, selfId: widget.selfId, highlightQuery: highlightQuery, @@ -317,17 +379,7 @@ class _ChatViewState extends State with RouteAware { @override Widget build(BuildContext context) { - // Swallow the first back gesture while the keyboard is visible so it - // dismisses the IME instead of popping the chat — matches platform UX - // expectations (e.g. WhatsApp/Telegram) and prevents accidental exits - // mid-typing. - final keyboardOpen = MediaQuery.viewInsetsOf(context).bottom > 0; - return PopScope( - canPop: !keyboardOpen, - onPopInvokedWithResult: (didPop, _) { - if (didPop) return; - FocusManager.instance.primaryFocus?.unfocus(); - }, + return _KeyboardDismissPopScope( child: Scaffold( backgroundColor: const Color(0xffefeae2), appBar: _searchActive @@ -376,25 +428,7 @@ class _ChatViewState extends State with RouteAware { state.currentToken == widget.room.token, enablePullToRefresh: false, child: (state, _) { - final items = _buildMessages( - state.chatResponse!, - ).reversed.toList(); - // reverse:true renders index 0 at the bottom, so the top - // marker (spinner / start-of-chat) goes at the end. - if (state.isLoadingOlder) { - items.add(const _LoadingOlderIndicator()); - } else if (!state.hasMoreOld) { - items.add( - ChatBubble( - isSender: false, - bubbleData: GetChatResponseObject.getTextDummy( - 'Anfang des Chats', - ), - chatData: widget.room, - refetch: ({bool renew = false}) => _refresh(), - ), - ); - } + final items = _itemsFor(state); _itemCount = items.length; return ScrollablePositionedList.builder( reverse: true, @@ -448,3 +482,27 @@ class _LoadingOlderIndicator extends StatelessWidget { ), ); } + +/// Swallows the first back gesture while the keyboard is visible so it +/// dismisses the IME instead of popping the chat — matches platform UX +/// expectations (e.g. WhatsApp/Telegram) and prevents accidental exits +/// mid-typing. A separate widget so the per-frame inset changes during the +/// keyboard animation only rebuild this scope, not the whole chat. +class _KeyboardDismissPopScope extends StatelessWidget { + final Widget child; + + const _KeyboardDismissPopScope({required this.child}); + + @override + Widget build(BuildContext context) { + final keyboardOpen = MediaQuery.viewInsetsOf(context).bottom > 0; + return PopScope( + canPop: !keyboardOpen, + onPopInvokedWithResult: (didPop, _) { + if (didPop) return; + FocusManager.instance.primaryFocus?.unfocus(); + }, + child: child, + ); + } +} diff --git a/lib/view/pages/talk/widgets/chat_bubble.dart b/lib/view/pages/talk/widgets/chat_bubble.dart index 936e800..cb58ecf 100644 --- a/lib/view/pages/talk/widgets/chat_bubble.dart +++ b/lib/view/pages/talk/widgets/chat_bubble.dart @@ -59,6 +59,13 @@ class _ChatBubbleState extends State with SingleTickerProviderStateMixin, DownloadTrigger { late ChatMessage message; + // The parsed message and its widget are only rebuilt when their inputs + // change. The bubble itself rebuilds on every chat emit, swipe frame and + // keyboard frame; re-running rich-object parsing, linkify/Markdown and emoji + // detection each time is what makes long chats stutter. + Object? _messageKey; + late Widget _messageWidget; + Offset _position = Offset.zero; Offset _dragStartPosition = Offset.zero; bool _swipeActionArmed = false; @@ -185,12 +192,34 @@ class _ChatBubbleState extends State } } - @override - Widget build(BuildContext context) { + void _updateMessage(BuildContext context) { + final style = _messageTextStyle(context); + final renderMarkdown = + widget.bubbleData.markdown && + widget.bubbleData.messageType == + GetRoomResponseObjectMessageType.comment; + final key = ( + widget.bubbleData, + widget.highlightQuery, + style, + renderMarkdown, + ); + if (key == _messageKey) return; + _messageKey = key; message = ChatMessage( originalMessage: widget.bubbleData.message, originalData: widget.bubbleData.messageParameters, ); + _messageWidget = message.getWidget( + highlightQuery: widget.highlightQuery, + style: style, + renderMarkdown: renderMarkdown, + ); + } + + @override + Widget build(BuildContext context) { + _updateMessage(context); final showActorDisplayName = _rendersAsCommentBubble && widget.chatData.type != GetRoomResponseObjectConversationType.oneToOne; @@ -277,14 +306,7 @@ class _ChatBubbleState extends State actorText: actorText, actorWidget: actorWidget, timeText: timeText, - messageWidget: message.getWidget( - highlightQuery: widget.highlightQuery, - style: _messageTextStyle(context), - renderMarkdown: - widget.bubbleData.markdown && - widget.bubbleData.messageType == - GetRoomResponseObjectMessageType.comment, - ), + messageWidget: _messageWidget, parent: parent, bubbleData: widget.bubbleData, isSender: widget.isSender, @@ -350,10 +372,12 @@ class _BubbleContent extends StatelessWidget { Widget build(BuildContext context) => MergeSemantics( child: Container( constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.9, + maxWidth: MediaQuery.sizeOf(context).width * 0.9, minWidth: showActorDisplayName - ? actorText.size.width - : timeText.size.width + (isSender ? spacing + timeIconSize : 0) + 3, + ? actorText.measuredWidth(context) + : timeText.measuredWidth(context) + + (isSender ? spacing + timeIconSize : 0) + + 3, ), child: Stack( children: [ diff --git a/lib/view/pages/talk/widgets/chat_bubble_reactions.dart b/lib/view/pages/talk/widgets/chat_bubble_reactions.dart index e7bd55c..9583670 100644 --- a/lib/view/pages/talk/widgets/chat_bubble_reactions.dart +++ b/lib/view/pages/talk/widgets/chat_bubble_reactions.dart @@ -34,7 +34,7 @@ class ChatBubbleReactions extends StatelessWidget { return Transform.translate( offset: const Offset(0, -10), child: Container( - width: MediaQuery.of(context).size.width, + width: MediaQuery.sizeOf(context).width, margin: const EdgeInsets.only(left: 15, right: 15), child: Wrap( alignment: isSender ? WrapAlignment.end : WrapAlignment.start, diff --git a/lib/view/pages/talk/widgets/chat_textfield.dart b/lib/view/pages/talk/widgets/chat_textfield.dart index 7769b5a..2ef9438 100644 --- a/lib/view/pages/talk/widgets/chat_textfield.dart +++ b/lib/view/pages/talk/widgets/chat_textfield.dart @@ -83,12 +83,20 @@ class _ChatTextfieldState extends State { ); } + /// Called per keystroke, so the text itself is saved silently; only a draft + /// appearing or disappearing notifies listeners (the chat list's marker). void _setDraft(String text) { - final talkSettings = settings.val(write: true).talkSettings; + final drafts = settings.val().talkSettings.drafts; + final hadDraft = drafts.containsKey(widget.sendToToken); if (text.isNotEmpty) { - talkSettings.drafts[widget.sendToToken] = text; + drafts[widget.sendToToken] = text; } else { - talkSettings.drafts.removeWhere((key, _) => key == widget.sendToToken); + drafts.remove(widget.sendToToken); + } + if (hadDraft != text.isNotEmpty) { + settings.val(write: true); + } else { + settings.saveSilently(); } } @@ -276,17 +284,22 @@ class _ChatTextfieldState extends State { @override Widget build(BuildContext context) { - final chatBloc = context.watch(); - final chatState = chatBloc.state.data; + final chatBloc = context.read(); + // Only the reply reference is rendered from the chat state; loading flags + // and paging emits don't need to rebuild the input. + final (referenceMessageId, chatResponse) = context.select( + (ChatBloc b) => ( + b.state.data?.referenceMessageId, + b.state.data?.chatResponse, + ), + ); Widget replyBanner = const SizedBox.shrink(); - if (chatState != null && - chatState.referenceMessageId != null && - chatState.chatResponse != null) { + if (referenceMessageId != null && chatResponse != null) { try { - final referenceMessage = chatState.chatResponse! - .sortByTimestamp() - .firstWhere((e) => e.id == chatState.referenceMessageId); + final referenceMessage = chatResponse.data.firstWhere( + (e) => e.id == referenceMessageId, + ); replyBanner = Row( children: [ Expanded( diff --git a/lib/view/pages/talk/widgets/chat_tile.dart b/lib/view/pages/talk/widgets/chat_tile.dart index a90b129..69cf23e 100644 --- a/lib/view/pages/talk/widgets/chat_tile.dart +++ b/lib/view/pages/talk/widgets/chat_tile.dart @@ -68,15 +68,25 @@ class _ChatTileState extends State { /// One-line preview of the last message: rich-object placeholders resolved, /// newlines flattened and — for Markdown messages — formatting stripped so /// the list shows readable text rather than raw markers. + /// + /// Memoised per last message: the tile rebuilds on every chat-list emit and + /// the Markdown strip is a full parse. Keyed by content, since every refresh + /// delivers new message objects. String _lastMessagePreview() { final last = widget.data.lastMessage; + final key = (last.id, last.message, last.markdown); + if (key == _previewFor) return _preview; final text = RichObjectStringProcessor.parseToString( last.message.replaceAll('\n', ' '), last.messageParameters, ); - return last.markdown ? markdownToPlainText(text) : text; + _previewFor = key; + return _preview = last.markdown ? markdownToPlainText(text) : text; } + Object? _previewFor; + String _preview = ''; + Future _setCurrentAsRead() async { final token = widget.data.token; final lastId = widget.data.lastMessage.id; @@ -89,7 +99,11 @@ class _ChatTileState extends State { @override Widget build(BuildContext context) { - final chatBloc = context.watch(); + // Only the open token matters here (split-view highlight); watching the + // whole bloc rebuilt every tile on each message of the open chat. + final currentToken = context.select( + (ChatBloc b) => b.state.data?.currentToken, + ); final isGroup = widget.data.type != GetRoomResponseObjectConversationType.oneToOne; final circleAvatar = UserAvatar( @@ -100,7 +114,7 @@ class _ChatTileState extends State { return ListTile( style: ListTileStyle.list, tileColor: - chatBloc.state.data?.currentToken == widget.data.token && + currentToken == widget.data.token && TalkNavigator.isSecondaryVisible(context) ? Theme.of(context).primaryColor.withAlpha(100) : null, diff --git a/lib/view/pages/timetable/data/calendar_logic.dart b/lib/view/pages/timetable/data/calendar_logic.dart index 56def74..54b9dd7 100644 --- a/lib/view/pages/timetable/data/calendar_logic.dart +++ b/lib/view/pages/timetable/data/calendar_logic.dart @@ -1,7 +1,7 @@ -import 'package:rrule/rrule.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart'; import '../../../../extensions/date_time.dart'; +import '../../../../utils/recurrence_occurrences.dart'; import 'arbitrary_appointment.dart'; import 'calendar_layout.dart'; import 'lesson_period_schedule.dart'; @@ -103,7 +103,6 @@ partitionAppointmentsForWeek( continue; } try { - final parsed = RecurrenceRule.fromString(rule); final anchorUtc = a.startTime.toUtc(); final duration = a.endTime.difference(a.startTime); // Day-keyed set of exception dates so occurrences scheduled for one @@ -112,9 +111,12 @@ partitionAppointmentsForWeek( final exceptionDayKeys = (a.recurrenceExceptionDates ?? const []) .map((d) => '${d.year}-${d.month}-${d.day}') .toSet(); - for (final occUtc in parsed.getInstances(start: anchorUtc)) { - if (!occUtc.isBefore(weekEndUtc)) break; - if (occUtc.isBefore(weekStartUtc)) continue; + for (final occUtc in RecurrenceOccurrences.between( + rule, + anchorUtc, + weekStartUtc, + weekEndUtc, + )) { final occLocal = occUtc.toLocal(); if (exceptionDayKeys.contains( '${occLocal.year}-${occLocal.month}-${occLocal.day}', diff --git a/lib/view/pages/timetable/timetable.dart b/lib/view/pages/timetable/timetable.dart index 366942a..23c178e 100644 --- a/lib/view/pages/timetable/timetable.dart +++ b/lib/view/pages/timetable/timetable.dart @@ -266,11 +266,12 @@ class _ViewingBanner extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final isFavorite = context - .watch() - .val() - .timetableFavoritesSettings - .isFavorite(element.type, element.id); + final isFavorite = context.select( + (SettingsCubit c) => c.state.timetableFavoritesSettings.isFavorite( + element.type, + element.id, + ), + ); final onColor = theme.colorScheme.onSecondaryContainer; // Compact icon button: ~32px square, no extra padding, so the banner stays diff --git a/lib/view/pages/timetable/widgets/timetable_calendar_view.dart b/lib/view/pages/timetable/widgets/timetable_calendar_view.dart index 5224f1f..c48f7fa 100644 --- a/lib/view/pages/timetable/widgets/timetable_calendar_view.dart +++ b/lib/view/pages/timetable/widgets/timetable_calendar_view.dart @@ -7,10 +7,10 @@ import '../../../../extensions/date_time.dart'; import '../../../../state/app/modules/capabilities/bloc/capabilities_cubit.dart'; import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../../state/app/modules/timetable/bloc/timetable_state.dart'; -import '../../../../storage/timetable_settings.dart'; import '../data/arbitrary_appointment.dart'; import '../data/lesson_period_schedule.dart'; import '../data/timetable_appointment_factory.dart'; +import '../data/timetable_name_mode.dart'; import 'custom_workweek_calendar.dart'; import 'special_regions_builder.dart'; @@ -51,9 +51,10 @@ class TimetableCalendarViewState extends State { GlobalKey(); List? _cachedAppointments; - // TimetableSettings and List define no `==`, so record equality degrades to - // the same identity checks the cache always used. - (int, TimetableSettings, List, bool)? _cacheKey; + // Settings are keyed by the values the factory reads: the settings object is + // re-created on every settings write, so its identity would miss the cache + // for unrelated changes. The event list has no `==` and compares by identity. + (int, bool, TimetableNameMode, List, bool)? _cacheKey; // Stable identities let the calendar reuse its per-week pages across // rebuilds; rebuilding these every frame would invalidate that cache. @@ -71,13 +72,16 @@ class TimetableCalendarViewState extends State { } List _appointments(TimetableState state) { - final timetableSettings = context - .watch() - .val() - .timetableSettings; + final (connectDoubleLessons, nameMode) = context.select( + (SettingsCubit c) => ( + c.state.timetableSettings.connectDoubleLessons, + c.state.timetableSettings.timetableNameMode, + ), + ); final key = ( state.dataVersion, - timetableSettings, + connectDoubleLessons, + nameMode, widget.customEvents, widget.showClassInsteadOfTeacher, ); @@ -91,7 +95,7 @@ class TimetableCalendarViewState extends State { customEvents: widget.customEvents, subjects: state.subjects?.result ?? const [], holidays: state.schoolHolidays?.result ?? const [], - settings: timetableSettings, + settings: context.read().val().timetableSettings, now: DateTime.now(), showClassInsteadOfTeacher: widget.showClassInsteadOfTeacher, ).build(); diff --git a/lib/widget/chat_background.dart b/lib/widget/chat_background.dart index 2505413..8118fb6 100644 --- a/lib/widget/chat_background.dart +++ b/lib/widget/chat_background.dart @@ -8,6 +8,7 @@ import '../state/app/modules/settings/bloc/settings_cubit.dart'; import '../storage/chat_background_settings.dart'; import '../theming/app_theme.dart'; import '../utils/app_paths.dart'; +import '../utils/screen_bound_image.dart'; /// Renders the configurable chat background behind [child]. /// @@ -24,7 +25,13 @@ class ChatBackground extends StatelessWidget { @override Widget build(BuildContext context) { - final s = context.watch().val().chatBackgroundSettings; + // Value snapshot: the settings object is mutated in place, so only copied + // values can tell whether the background actually changed. + context.select((SettingsCubit c) { + final s = c.state.chatBackgroundSettings; + return (s.type, s.fit, s.colorValue, s.imageVersion, s.dim, s.blur); + }); + final s = context.read().val().chatBackgroundSettings; final dark = AppTheme.isDarkMode(context); final Widget background; @@ -32,7 +39,9 @@ class ChatBackground extends StatelessWidget { case ChatBackgroundType.none: background = ColoredBox(color: Theme.of(context).colorScheme.surface); case ChatBackgroundType.color: - background = ColoredBox(color: Color(s.colorValue ?? _fallbackColor.toARGB32())); + background = ColoredBox( + color: Color(s.colorValue ?? _fallbackColor.toARGB32()), + ); case ChatBackgroundType.pattern: background = _imageLayer( const AssetImage('assets/background/chat.png'), @@ -41,12 +50,17 @@ class ChatBackground extends StatelessWidget { isPattern: true, ); case ChatBackgroundType.image: + final image = FileImage(File(AppPaths.chatBackgroundImage)); background = KeyedSubtree( // imageVersion changes on every replacement, forcing a fresh subtree // alongside the explicit ImageCache evict in the settings handler. key: ValueKey(s.imageVersion), + // Only "cover" scales the photo to the screen; tile/center render + // it unscaled, so those keep the natural size. child: _imageLayer( - FileImage(File(AppPaths.chatBackgroundImage)), + s.fit == ChatBackgroundFit.cover + ? screenBoundImage(context, image) + : image, s, dark, isPattern: false, @@ -57,7 +71,9 @@ class ChatBackground extends StatelessWidget { return Stack( fit: StackFit.expand, children: [ - Positioned.fill(child: background), + // Own layer: without it every scroll frame of the message list above + // repaints the (possibly tiled or blurred) background as well. + Positioned.fill(child: RepaintBoundary(child: background)), if (s.dim > 0) Positioned.fill( child: ColoredBox(color: Colors.black.withValues(alpha: s.dim)), diff --git a/lib/widget/debug/cache_view.dart b/lib/widget/debug/cache_view.dart index ff78296..75cd750 100644 --- a/lib/widget/debug/cache_view.dart +++ b/lib/widget/debug/cache_view.dart @@ -2,10 +2,9 @@ import 'dart:convert'; import 'package:filesize/filesize.dart'; import 'package:flutter/material.dart'; import 'package:jiffy/jiffy.dart'; -import 'package:localstore/localstore.dart'; import '../../../widget/placeholder_view.dart'; -import '../../api/request_cache.dart'; +import '../../api/cache_store.dart'; import '../app_progress_indicator.dart'; import 'json_viewer.dart'; @@ -15,31 +14,11 @@ class CacheView extends StatefulWidget { @override State createState() => _CacheViewState(); - Future clear() async { - await Localstore.instance.collection(RequestCache.collection).delete(); - } - - Future totalSize() async { - final data = await Localstore.instance - .collection(RequestCache.collection) - .get(); - if (data == null || data.isEmpty) return 0; - return data.values.fold( - 0, - (sum, value) => sum + jsonEncode(value).length, - ) * - 8; - } } class _CacheViewState extends State { - late Future?> files; - - @override - void initState() { - files = Localstore.instance.collection(RequestCache.collection).get(); - super.initState(); - } + late final Future> files = CacheStore.instance + .readAll(); @override Widget build(BuildContext context) => Scaffold( @@ -47,24 +26,23 @@ class _CacheViewState extends State { body: FutureBuilder( future: files, builder: (context, snapshot) { - if (snapshot.hasData) { + if (snapshot.hasData && snapshot.data!.isNotEmpty) { return ListView.builder( itemCount: snapshot.data!.length, itemBuilder: (context, index) { final key = snapshot.data!.keys.elementAt(index); - final element = snapshot.data![key] as Map; - final filename = key.split('/').last; + final element = snapshot.data![key]!; return ListTile( leading: const Icon(Icons.text_snippet_outlined), - title: Text(filename), + title: Text(key), subtitle: Text( - '${filesize(jsonEncode(element).length * 8)}, ${Jiffy.parseFromMillisecondsSinceEpoch(element['lastupdate'] as int).fromNow()}', + '${filesize(utf8.encode(element.json).length)}, ${Jiffy.parseFromMillisecondsSinceEpoch(element.lastUpdate).fromNow()}', ), trailing: const Icon(Icons.arrow_right), onTap: () => JsonViewer.asDialog( context, - jsonDecode(element['json'] as String) as Map, + jsonDecode(element.json) as Map, ), ); }, diff --git a/lib/widget/file_viewer.dart b/lib/widget/file_viewer.dart index e659ead..17a0b2d 100644 --- a/lib/widget/file_viewer.dart +++ b/lib/widget/file_viewer.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'dart:math'; import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_svg/flutter_svg.dart'; @@ -15,6 +16,7 @@ import '../routing/app_routes.dart'; import '../share_intent/remote_file_ref.dart'; import '../state/app/modules/settings/bloc/settings_cubit.dart'; import '../utils/downloads/download_manager.dart'; +import '../utils/screen_bound_image.dart'; import 'app_progress_indicator.dart'; import 'async_action_button.dart'; import 'centered_leading.dart'; @@ -49,6 +51,7 @@ class FileViewer extends StatefulWidget { enum FileViewingActions { openExternal, share, save, sendToChat, saveToCloud } class _FileViewerState extends State { + Future<_TextPayload>? _textPayload; final PhotoViewController photoViewController = PhotoViewController(); late SettingsCubit settings = context.read(); @@ -302,7 +305,13 @@ class _FileViewerState extends State { controller: photoViewController, maxScale: 3.0, minScale: 0.1, - imageProvider: Image.file(File(widget.path)).image, + // 2× the screen stays sharp while zooming in; the 4096 px cap only + // bites on camera-sized photos. + imageProvider: screenBoundImage( + context, + FileImage(File(widget.path)), + scale: 2, + ), backgroundDecoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, ), @@ -348,13 +357,17 @@ class _FileViewerState extends State { Widget _buildTextView() => Scaffold( appBar: _appbar(), body: FutureBuilder<_TextPayload>( - future: _readTextPayload(), + // Cached: a future created in build re-read the file on every rebuild. + future: _textPayload ??= compute( + _loadTextPayload, + (widget.path, _textViewMaxBytes), + ), builder: (context, snapshot) { if (!snapshot.hasData) { return const Center(child: AppProgressIndicator.large()); } final payload = snapshot.data!; - final lines = const LineSplitter().convert(payload.content); + final lines = payload.lines; // Stable gutter width — sized by the highest line number's digit count. final gutterWidth = (lines.length.toString().length * 9.0) + 16; return SelectionArea( @@ -443,38 +456,6 @@ class _FileViewerState extends State { } static const int _textViewMaxBytes = 5 * 1024 * 1024; - - Future<_TextPayload> _readTextPayload() async { - final file = File(widget.path); - final size = await file.length(); - final ext = widget.path.split('.').last.toLowerCase(); - if (size <= _textViewMaxBytes) { - final raw = await file.readAsString(); - return _TextPayload(content: _maybePrettify(raw, ext), truncated: false); - } - final raf = await file.open(); - try { - final bytes = await raf.read(_textViewMaxBytes); - // Truncated payloads stay raw — a parser would choke on the dangling tail. - return _TextPayload( - content: utf8.decode(bytes, allowMalformed: true), - truncated: true, - ); - } finally { - await raf.close(); - } - } - - /// Falls through to the original text on parse errors. - String _maybePrettify(String content, String ext) { - if (ext != 'json') return content; - try { - final parsed = jsonDecode(content); - return const JsonEncoder.withIndent(' ').convert(parsed); - } on Object { - return content; - } - } } class _ActionDescriptor { @@ -489,7 +470,47 @@ class _ActionDescriptor { } class _TextPayload { - final String content; + final List lines; final bool truncated; - const _TextPayload({required this.content, required this.truncated}); + const _TextPayload({required this.lines, required this.truncated}); +} + +/// Reads, prettifies (JSON) and splits a text file on a background isolate: +/// up to 5 MB of decoding and line splitting would otherwise freeze the UI. +Future<_TextPayload> _loadTextPayload((String, int) args) async { + final (path, maxBytes) = args; + final file = File(path); + final size = await file.length(); + final ext = path.split('.').last.toLowerCase(); + if (size <= maxBytes) { + final raw = await file.readAsString(); + return _TextPayload( + lines: const LineSplitter().convert(_maybePrettify(raw, ext)), + truncated: false, + ); + } + final raf = await file.open(); + try { + final bytes = await raf.read(maxBytes); + // Truncated payloads stay raw — a parser would choke on the dangling tail. + return _TextPayload( + lines: const LineSplitter().convert( + utf8.decode(bytes, allowMalformed: true), + ), + truncated: true, + ); + } finally { + await raf.close(); + } +} + +/// Falls through to the original text on parse errors. +String _maybePrettify(String content, String ext) { + if (ext != 'json') return content; + try { + final parsed = jsonDecode(content); + return const JsonEncoder.withIndent(' ').convert(parsed); + } on Object { + return content; + } } diff --git a/lib/widget/prosemirror/pm_image_view.dart b/lib/widget/prosemirror/pm_image_view.dart index 950d54d..1a347f4 100644 --- a/lib/widget/prosemirror/pm_image_view.dart +++ b/lib/widget/prosemirror/pm_image_view.dart @@ -10,9 +10,33 @@ class PmImageView extends StatelessWidget { const PmImageView({required this.node, super.key}); + /// Full-resolution source, used for the zoomable fullscreen view. + static ImageProvider? sourceProvider(PmImage node) { + final bytes = node.bytes; + if (bytes != null) return MemoryImage(bytes); + if (node.src.startsWith('http')) { + return CachedNetworkImageProvider(node.src); + } + return null; + } + + /// Inline images never render wider than the screen, so they are decoded at + /// most at its physical width instead of the (often camera-sized) original. + /// [PmJsonView] precaches through this too, so both hit the same cache key. + static ImageProvider? inlineProvider(BuildContext context, PmImage node) { + final source = sourceProvider(node); + if (source == null) return null; + final width = + (MediaQuery.sizeOf(context).width * + MediaQuery.devicePixelRatioOf(context)) + .round(); + return ResizeImage(source, width: width); + } + @override Widget build(BuildContext context) { - final provider = _imageProvider(); + final provider = sourceProvider(node); + final inline = inlineProvider(context, node); return LayoutBuilder( builder: (context, constraints) { final available = constraints.maxWidth.isFinite @@ -22,7 +46,13 @@ class PmImageView extends StatelessWidget { Widget image = ConstrainedBox( constraints: BoxConstraints(maxWidth: targetWidth ?? available), - child: _imageWidget(context, provider), + child: inline == null + ? _brokenImage(context) + : Image( + image: inline, + errorBuilder: (context, error, stack) => + _brokenImage(context), + ), ); final href = node.href; @@ -43,30 +73,6 @@ class PmImageView extends StatelessWidget { ); } - ImageProvider? _imageProvider() { - if (node.bytes != null) return MemoryImage(node.bytes!); - if (node.src.startsWith('http')) { - return CachedNetworkImageProvider(node.src); - } - return null; - } - - Widget _imageWidget(BuildContext context, ImageProvider? provider) { - if (node.bytes != null) { - return Image.memory( - node.bytes!, - errorBuilder: (context, error, stack) => _brokenImage(context), - ); - } - if (node.src.startsWith('http')) { - return CachedNetworkImage( - imageUrl: node.src, - errorWidget: (context, url, error) => _brokenImage(context), - ); - } - return _brokenImage(context); - } - Widget _brokenImage(BuildContext context) { final theme = Theme.of(context); return DecoratedBox( diff --git a/lib/widget/prosemirror/pm_json_view.dart b/lib/widget/prosemirror/pm_json_view.dart index d17bfeb..077171c 100644 --- a/lib/widget/prosemirror/pm_json_view.dart +++ b/lib/widget/prosemirror/pm_json_view.dart @@ -1,9 +1,9 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import '../app_progress_indicator.dart'; import 'pm_document_view.dart'; +import 'pm_image_view.dart'; import 'pm_node.dart'; /// Renders raw ProseMirror JSON and memoises the parsed tree across rebuilds. @@ -31,7 +31,7 @@ class PmJsonView extends StatefulWidget { class _PmJsonViewState extends State { PmNode? _shown; PmNode? _pending; - List _pendingProviders = const []; + List _pendingImages = const []; bool _precacheStarted = false; int _generation = 0; @@ -62,17 +62,17 @@ class _PmJsonViewState extends State { void _parse() { final doc = PmNode.fromJson(widget.json); - final providers = []; - _collectProviders(doc, providers); + final images = []; + _collectImages(doc, images); _generation++; _precacheStarted = false; - if (providers.isEmpty) { + if (images.isEmpty) { _shown = doc; _pending = null; - _pendingProviders = const []; + _pendingImages = const []; } else { _pending = doc; - _pendingProviders = providers; + _pendingImages = images; } } @@ -82,8 +82,9 @@ class _PmJsonViewState extends State { _precacheStarted = true; final generation = _generation; Future.wait([ - for (final provider in _pendingProviders) - precacheImage(provider, context, onError: (_, _) {}), + for (final image in _pendingImages) + if (PmImageView.inlineProvider(context, image) case final provider?) + precacheImage(provider, context, onError: (_, _) {}), ]) .timeout(PmJsonView.precacheTimeout, onTimeout: () => const []) .whenComplete(() { @@ -91,23 +92,16 @@ class _PmJsonViewState extends State { setState(() { _shown = pending; _pending = null; - _pendingProviders = const []; + _pendingImages = const []; }); } }); } - void _collectProviders(PmNode node, List out) { - if (node is PmImage) { - final bytes = node.bytes; - if (bytes != null) { - out.add(MemoryImage(bytes)); - } else if (node.src.startsWith('http')) { - out.add(CachedNetworkImageProvider(node.src)); - } - } + void _collectImages(PmNode node, List out) { + if (node is PmImage) out.add(node); for (final child in node.children) { - _collectProviders(child, out); + _collectImages(child, out); } } diff --git a/lib/widget/share_position_origin.dart b/lib/widget/share_position_origin.dart index 2086f09..9a27a61 100644 --- a/lib/widget/share_position_origin.dart +++ b/lib/widget/share_position_origin.dart @@ -4,7 +4,7 @@ class SharePositionOrigin { static Rect get(BuildContext context) => Rect.fromLTWH( 0, 0, - MediaQuery.of(context).size.width, - MediaQuery.of(context).size.height / 2, + MediaQuery.sizeOf(context).width, + MediaQuery.sizeOf(context).height / 2, ); } diff --git a/lib/widget/user_avatar.dart b/lib/widget/user_avatar.dart index 3db7fb8..5556d6c 100644 --- a/lib/widget/user_avatar.dart +++ b/lib/widget/user_avatar.dart @@ -389,6 +389,10 @@ class _UserAvatarState extends State { payload.bytes, width: radius * 2, height: radius * 2, + // Group avatars arrive at a fixed large server size; decoding them + // at display size keeps a chat list from filling the image cache. + cacheWidth: (radius * 2 * MediaQuery.devicePixelRatioOf(context)) + .round(), fit: BoxFit.cover, gaplessPlayback: true, ); diff --git a/lib/widget_data/widget_data_mapper.dart b/lib/widget_data/widget_data_mapper.dart index eb30bda..1e2bd1a 100644 --- a/lib/widget_data/widget_data_mapper.dart +++ b/lib/widget_data/widget_data_mapper.dart @@ -1,7 +1,5 @@ import 'dart:developer'; -import 'package:rrule/rrule.dart'; - import '../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart'; import '../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms_response.dart'; import '../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart'; @@ -10,6 +8,7 @@ import '../api/marianumconnect/queries/timetable_get_week/timetable_get_week_res import '../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart'; import '../extensions/date_time.dart'; +import '../utils/recurrence_occurrences.dart'; import '../view/pages/timetable/data/lesson_labels.dart'; import '../view/pages/timetable/data/lesson_merger.dart'; import '../view/pages/timetable/data/lesson_period_schedule.dart'; @@ -443,11 +442,13 @@ class WidgetDataMapper { } try { - final parsed = RecurrenceRule.fromString(rule); final anchorUtc = event.startDate.toUtc(); - for (final occUtc in parsed.getInstances(start: anchorUtc)) { - if (!occUtc.isBefore(rangeEndUtc)) break; - if (occUtc.isBefore(rangeStartUtc)) continue; + for (final occUtc in RecurrenceOccurrences.between( + rule, + anchorUtc, + rangeStartUtc, + rangeEndUtc, + )) { final occLocal = occUtc.toLocal(); final occStart = DateTime( occLocal.year, diff --git a/lib/widget_data/widget_publisher.dart b/lib/widget_data/widget_publisher.dart index e5233f6..d087234 100644 --- a/lib/widget_data/widget_publisher.dart +++ b/lib/widget_data/widget_publisher.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:developer'; import 'package:flutter/foundation.dart'; @@ -20,22 +21,65 @@ class WidgetPublisher { static DateTime widgetNow() => kDebugMode ? DateTime.now().add(debugTimeShift) : DateTime.now(); + /// Identical snapshots are still re-written after this long, so the stored + /// `fetchedAt` (freshness for the background refresh) doesn't go stale. + static const Duration _republishAfter = Duration(minutes: 10); + + static Future _queue = Future.value(); + static (bool, String, bool)? _lastFlags; + static String? _lastSignature; + static DateTime? _lastPublishedAt; + + /// Forgets what was written last, so the next publish writes again. Called + /// whenever the stored widget data is cleared (sign-out). + static void resetDedupe() { + _lastSignature = null; + _lastPublishedAt = null; + } + + /// Publishes run one at a time: they are fire-and-forget from the bloc + /// stream, and interleaved writes could leave day and week data from + /// different states behind. + /// [epoch] is the session the [state] belongs to; defaults to the current + /// one. Callers that delay the publish must capture it up front. static Future publishFromBlocState( TimetableState state, { Settings? settings, bool isTeacher = false, + int? epoch, + }) { + final sessionEpoch = epoch ?? AccountData().sessionEpoch; + return _queue = _queue.then( + (_) => _publish( + state, + settings: settings, + isTeacher: isTeacher, + epoch: sessionEpoch, + ), + ); + } + + static Future _publish( + TimetableState state, { + required Settings? settings, + required bool isTeacher, + required int epoch, }) async { - final epoch = AccountData().sessionEpoch; + if (!AccountData().isCurrentSession(epoch)) return; try { final connectDouble = settings?.timetableSettings.connectDoubleLessons ?? true; // Mirror into widget storage so the background isolate sees the same // values the user just toggled — concurrently, they are independent. - await Future.wait([ - WidgetSync.setConnectDoubleLessons(connectDouble), - WidgetSync.setThemeMode(_themeName(settings?.appTheme)), - WidgetSync.setIsTeacher(isTeacher), - ]); + final flags = (connectDouble, _themeName(settings?.appTheme), isTeacher); + if (flags != _lastFlags) { + await Future.wait([ + WidgetSync.setConnectDoubleLessons(flags.$1), + WidgetSync.setThemeMode(flags.$2), + WidgetSync.setIsTeacher(flags.$3), + ]); + _lastFlags = flags; + } final lessons = state.getAllKnownLessons(); final now = widgetNow(); final dayData = WidgetDataMapper.buildDayData( @@ -63,6 +107,20 @@ class WidgetPublisher { // A publish still running at sign-out would put the previous account's // plan back onto the just cleared widget. if (!AccountData().isCurrentSession(epoch)) return; + // Most bloc emits (week swipes, prefetches) don't touch the widget's + // window; skip the SharedPreferences commits and widget re-render then. + final signature = jsonEncode([ + _withoutFetchedAt(dayData.toJson()), + _withoutFetchedAt(weekData.toJson()), + ]); + final lastAt = _lastPublishedAt; + if (signature == _lastSignature && + lastAt != null && + now.difference(lastAt) < _republishAfter) { + return; + } + _lastSignature = signature; + _lastPublishedAt = now; await WidgetSync.writeDayData(dayData); await WidgetSync.writeWeekData(weekData); await WidgetSync.setLoggedIn(true); @@ -74,6 +132,9 @@ class WidgetPublisher { } } + static Map _withoutFetchedAt(Map json) => + Map.of(json)..remove('fetchedAt'); + static String _themeName(ThemeMode? mode) { switch (mode) { case ThemeMode.light: diff --git a/lib/widget_data/widget_sync.dart b/lib/widget_data/widget_sync.dart index 500fc36..e5de012 100644 --- a/lib/widget_data/widget_sync.dart +++ b/lib/widget_data/widget_sync.dart @@ -5,6 +5,7 @@ import 'dart:developer'; import 'package:home_widget/home_widget.dart'; import 'widget_data.dart'; +import 'widget_publisher.dart'; /// Bridge to the native widget host. All keys/names live here so the Kotlin /// and Swift sides stay in sync. @@ -109,6 +110,7 @@ class WidgetSync { } static Future clear() async { + WidgetPublisher.resetDedupe(); await ensureInitialized(); await HomeWidget.saveWidgetData(dayDataKey, null); await HomeWidget.saveWidgetData(weekDataKey, null); diff --git a/test/api/cache_store_test.dart b/test/api/cache_store_test.dart new file mode 100644 index 0000000..ae1d676 --- /dev/null +++ b/test/api/cache_store_test.dart @@ -0,0 +1,21 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/api/cache_store.dart'; + +void main() { + group('CacheStore.parse', () { + test('splits the timestamp header from the payload', () { + final entry = CacheStore.parse('1700000000000\n{"a":"b\\nc"}'); + expect(entry!.lastUpdate, 1700000000000); + expect(entry.json, '{"a":"b\\nc"}'); + }); + + test('keeps newlines inside the payload', () { + expect(CacheStore.parse('1\nline1\nline2')!.json, 'line1\nline2'); + }); + + test('rejects files without a valid header', () { + expect(CacheStore.parse('{"a":1}'), isNull); + expect(CacheStore.parse('abc\n{}'), isNull); + }); + }); +} diff --git a/test/state/timetable_reference_data_test.dart b/test/state/timetable_reference_data_test.dart new file mode 100644 index 0000000..4f78de7 --- /dev/null +++ b/test/state/timetable_reference_data_test.dart @@ -0,0 +1,32 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms_response.dart'; +import 'package:marianum_mobile/state/app/modules/timetable/bloc/timetable_state.dart'; + +TimetableGetRoomsResponse _rooms(String name) => TimetableGetRoomsResponse( + result: [McRoom(id: 1, shortName: name, longName: name)], +); + +void main() { + final base = TimetableState( + startDate: DateTime(2026, 9, 21), + endDate: DateTime(2026, 9, 25), + rooms: _rooms('A101'), + dataVersion: 3, + ); + + test('returns the same state when the content is unchanged', () { + expect(base.withReferenceData(rooms: _rooms('A101')), same(base)); + }); + + test('bumps dataVersion and takes the new value when content changed', () { + final next = _rooms('B202'); + final result = base.withReferenceData(rooms: next); + expect(result.rooms, same(next)); + expect(result.dataVersion, 4); + }); + + test('keeps unchanged instances when only another field changed', () { + final result = base.withReferenceData(rooms: _rooms('A101')); + expect(result.rooms, same(base.rooms)); + }); +} diff --git a/test/utils/recurrence_occurrences_test.dart b/test/utils/recurrence_occurrences_test.dart new file mode 100644 index 0000000..0b4f7fb --- /dev/null +++ b/test/utils/recurrence_occurrences_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/utils/recurrence_occurrences.dart'; + +void main() { + setUp(RecurrenceOccurrences.clear); + + final anchor = DateTime.utc(2024, 9, 2, 8); + + test('returns only occurrences inside the half-open range', () { + final hits = RecurrenceOccurrences.between( + 'RRULE:FREQ=DAILY', + anchor, + DateTime.utc(2026, 9, 21), + DateTime.utc(2026, 9, 24), + ); + expect(hits, [ + DateTime.utc(2026, 9, 21, 8), + DateTime.utc(2026, 9, 22, 8), + DateTime.utc(2026, 9, 23, 8), + ]); + }); + + test('answers earlier ranges after a later one was expanded', () { + RecurrenceOccurrences.between( + 'RRULE:FREQ=WEEKLY', + anchor, + DateTime.utc(2026, 1, 1), + DateTime.utc(2026, 1, 31), + ); + final hits = RecurrenceOccurrences.between( + 'RRULE:FREQ=WEEKLY', + anchor, + DateTime.utc(2024, 9, 1), + DateTime.utc(2024, 9, 10), + ); + expect(hits, [DateTime.utc(2024, 9, 2, 8), DateTime.utc(2024, 9, 9, 8)]); + }); + + test('stops at the end of a finite series', () { + final hits = RecurrenceOccurrences.between( + 'RRULE:FREQ=DAILY;COUNT=2', + anchor, + DateTime.utc(2024, 1, 1), + DateTime.utc(2030, 1, 1), + ); + expect(hits, hasLength(2)); + }); +} diff --git a/test/view/files/local_cache_search_test.dart b/test/view/files/local_cache_search_test.dart index c1a0868..ebb487d 100644 --- a/test/view/files/local_cache_search_test.dart +++ b/test/view/files/local_cache_search_test.dart @@ -11,13 +11,10 @@ CacheableFile _file({ bool isDirectory = false, }) => CacheableFile(path: path, isDirectory: isDirectory, name: name); -Map _doc(ListFilesResponse listing) => { - 'json': jsonEncode(listing.toJson()), - 'lastupdate': 0, -}; +String _payload(ListFilesResponse listing) => jsonEncode(listing.toJson()); void main() { - group('searchLocalCaches', () { + group('local cache index', () { final root = ListFilesResponse({ _file(path: 'Documents/', name: 'Documents', isDirectory: true), _file(path: 'Photos/', name: 'Photos', isDirectory: true), @@ -27,51 +24,45 @@ void main() { _file(path: 'Documents/Tax-Report.pdf', name: 'Tax-Report.pdf'), _file(path: 'Documents/Notes.txt', name: 'Notes.txt'), }); - final docs = { - '/MarianumMobile/wd-folder-aaa': _doc(root), - '/MarianumMobile/wd-folder-bbb': _doc(documents), - '/MarianumMobile/get-room-ccc': {'json': '{}', 'lastupdate': 0}, - }; + final index = buildLocalCacheIndex([ + _payload(root), + _payload(documents), + ]); - test('matches by name case-insensitively across all caches', () async { - final hits = await searchLocalCaches('report', docs: docs); + test('matches by name case-insensitively across all caches', () { + final hits = searchLocalCacheIndex(index, 'report'); final paths = hits.map((f) => f.path).toSet(); expect(paths, {'Reports.pdf', 'Documents/Tax-Report.pdf'}); }); - test('returns empty list for empty query', () async { - expect(await searchLocalCaches(' ', docs: docs), isEmpty); + test('returns empty list for empty query', () { + expect(searchLocalCacheIndex(index, ' '), isEmpty); }); - test('respects pathScope prefix', () async { - final hits = await searchLocalCaches( + test('respects pathScope prefix', () { + final hits = searchLocalCacheIndex( + index, 'report', pathScope: ['Documents'], - docs: docs, ); expect(hits.map((f) => f.path), ['Documents/Tax-Report.pdf']); }); - test('ignores non-folder cache documents', () async { - final hits = await searchLocalCaches('anything', docs: docs); - // Only documents starting with `wd-folder-` are scanned. The unrelated - // `get-room-ccc` doc must not crash the helper. - expect(hits, isEmpty); + test('skips payloads that are not folder listings', () { + expect(buildLocalCacheIndex(['not json', '{"foo": 1}']), isEmpty); }); - test('deduplicates entries that appear in multiple cached folders', - () async { + test('deduplicates entries that appear in multiple cached folders', () { final shared = _file( path: 'Documents/Tax-Report.pdf', name: 'Tax-Report.pdf', ); final dedupRoot = ListFilesResponse({shared}); - final dedupDocs = { - '/MarianumMobile/wd-folder-aaa': _doc(dedupRoot), - '/MarianumMobile/wd-folder-bbb': _doc(dedupRoot), - }; - final hits = await searchLocalCaches('tax', docs: dedupDocs); - expect(hits, hasLength(1)); + final dedupIndex = buildLocalCacheIndex([ + _payload(dedupRoot), + _payload(dedupRoot), + ]); + expect(searchLocalCacheIndex(dedupIndex, 'tax'), hasLength(1)); }); }); } From d6130584f2d7fdc1a972007c019cf5e516b30c6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Sat, 26 Sep 2026 00:06:18 +0200 Subject: [PATCH 08/18] fixed several runtime errors from client error reports --- lib/notification/notification_service.dart | 5 ++++ .../chat_list/bloc/chat_list_bloc.dart | 2 +- lib/view/pages/files/files_upload_dialog.dart | 4 ++- lib/widget/avatar_actions_sheet.dart | 4 +-- lib/widget/chat_background_picker_sheet.dart | 6 ++-- lib/widget/file_pick.dart | 28 ++++++++++++++++--- 6 files changed, 39 insertions(+), 10 deletions(-) diff --git a/lib/notification/notification_service.dart b/lib/notification/notification_service.dart index dd5819f..bdd4953 100644 --- a/lib/notification/notification_service.dart +++ b/lib/notification/notification_service.dart @@ -25,7 +25,12 @@ class NotificationService { // iOS Talk category mirrors the Android inline reply + mark-as-read actions // so both platforms expose the same quick actions. The actual delivery of // these while the app is terminated is handled by the (Phase 3) NSE. + // The plugin defaults to prompting here, which would block startup on the + // OS dialog; the prompt belongs to maybePromptTalkNotifications instead. final iosSettings = DarwinInitializationSettings( + requestAlertPermission: false, + requestSoundPermission: false, + requestBadgePermission: false, notificationCategories: [ DarwinNotificationCategory( PushRenderer.iosTalkCategory, diff --git a/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart b/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart index c1cd1f5..24128f8 100644 --- a/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart +++ b/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart @@ -129,7 +129,7 @@ class ChatListBloc /// A background poll that returns the same rooms would otherwise rebuild the /// list and re-persist the whole state every 15 s. bool _isUnchanged(GetRoomResponse rooms) { - final encoded = jsonEncode(rooms.data); + final encoded = jsonEncode(rooms.data.toList()); final unchanged = encoded == _lastRoomsJson && innerState?.rooms != null && diff --git a/lib/view/pages/files/files_upload_dialog.dart b/lib/view/pages/files/files_upload_dialog.dart index 0288c78..2b63619 100644 --- a/lib/view/pages/files/files_upload_dialog.dart +++ b/lib/view/pages/files/files_upload_dialog.dart @@ -79,6 +79,7 @@ class _FilesUploadDialogState extends State { }); final webdavClient = await WebdavApi.webdav; + if (!mounted) return; if (!override) { List result; @@ -148,6 +149,7 @@ class _FilesUploadDialogState extends State { ), ); + if (!mounted) return; if (replaceFiles != true) { setState(() { _resetProgress(); @@ -230,8 +232,8 @@ class _FilesUploadDialogState extends State { uploadetFilePaths.add(fullRemotePath); } - setState(_resetProgress); if (!mounted) return; + setState(_resetProgress); Navigator.of(context).pop(); widget.onUploadFinished(uploadetFilePaths); } diff --git a/lib/widget/avatar_actions_sheet.dart b/lib/widget/avatar_actions_sheet.dart index 5e1a1e0..901975e 100644 --- a/lib/widget/avatar_actions_sheet.dart +++ b/lib/widget/avatar_actions_sheet.dart @@ -42,7 +42,7 @@ Future showAvatarActionsSheet( onTap: () async { final bytes = await _pickAndCrop( sheetContext, - () => FilePick.singleGalleryPick(sheetContext), + () => FilePick.singleGalleryPick(sheetContext, forCrop: true), ); if (bytes == null || !sheetContext.mounted) return; result = AvatarUploadResult(bytes); @@ -55,7 +55,7 @@ Future showAvatarActionsSheet( onTap: () async { final bytes = await _pickAndCrop( sheetContext, - () => FilePick.cameraPick(sheetContext), + () => FilePick.cameraPick(sheetContext, forCrop: true), ); if (bytes == null || !sheetContext.mounted) return; result = AvatarUploadResult(bytes); diff --git a/lib/widget/chat_background_picker_sheet.dart b/lib/widget/chat_background_picker_sheet.dart index 6f29474..cbae7d0 100644 --- a/lib/widget/chat_background_picker_sheet.dart +++ b/lib/widget/chat_background_picker_sheet.dart @@ -22,7 +22,7 @@ Future showChatBackgroundPickerSheet(BuildContext context) async { title: const Text('Aus Galerie wählen'), onTap: () async { final bytes = await _pickRaw( - () => FilePick.singleGalleryPick(sheetContext), + () => FilePick.singleGalleryPick(sheetContext, forCrop: true), ); if (bytes == null || !sheetContext.mounted) return; result = bytes; @@ -33,7 +33,9 @@ Future showChatBackgroundPickerSheet(BuildContext context) async { leading: const Icon(Icons.photo_camera_outlined), title: const Text('Foto aufnehmen'), onTap: () async { - final bytes = await _pickRaw(() => FilePick.cameraPick(sheetContext)); + final bytes = await _pickRaw( + () => FilePick.cameraPick(sheetContext, forCrop: true), + ); if (bytes == null || !sheetContext.mounted) return; result = bytes; Navigator.of(sheetContext).pop(); diff --git a/lib/widget/file_pick.dart b/lib/widget/file_pick.dart index 40be7e0..7f2578e 100644 --- a/lib/widget/file_pick.dart +++ b/lib/widget/file_pick.dart @@ -23,11 +23,31 @@ class FilePick { return pickedImages.isNotEmpty ? pickedImages : null; }); - static Future singleGalleryPick(BuildContext context) => - _guarded(context, () => _picker.pickImage(source: ImageSource.gallery)); + static Future singleGalleryPick( + BuildContext context, { + bool forCrop = false, + }) => _pickImage(context, ImageSource.gallery, forCrop: forCrop); - static Future cameraPick(BuildContext context) => - _guarded(context, () => _picker.pickImage(source: ImageSource.camera)); + static Future cameraPick( + BuildContext context, { + bool forCrop = false, + }) => _pickImage(context, ImageSource.camera, forCrop: forCrop); + + /// [forCrop] makes the picker re-encode to a bounded JPEG: the Dart cropper + /// can't decode HEIC (iOS gallery default) and is slow on full-size photos. + static Future _pickImage( + BuildContext context, + ImageSource source, { + required bool forCrop, + }) => _guarded( + context, + () => _picker.pickImage( + source: source, + maxWidth: forCrop ? 2048 : null, + maxHeight: forCrop ? 2048 : null, + imageQuality: forCrop ? 90 : null, + ), + ); static Future?> documentPick() async { final result = await FilePicker.pickFiles(allowMultiple: true); From 0f6c388940fbd718ed512e3e0b00b6121252029f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Sat, 26 Sep 2026 14:34:47 +0200 Subject: [PATCH 09/18] fixed overflowing account name and dropdowns in settings --- .../settings/sections/account_section.dart | 17 ++++++---- .../widgets/settings_dropdown_tile.dart | 32 +++++++++++++++---- 2 files changed, 35 insertions(+), 14 deletions(-) diff --git a/lib/view/pages/settings/sections/account_section.dart b/lib/view/pages/settings/sections/account_section.dart index 797bc93..5b82b30 100644 --- a/lib/view/pages/settings/sections/account_section.dart +++ b/lib/view/pages/settings/sections/account_section.dart @@ -136,14 +136,17 @@ class _AccountSectionState extends State { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Text( - displayName ?? username, - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.w600, + FittedBox( + fit: BoxFit.scaleDown, + alignment: AlignmentDirectional.centerStart, + child: Text( + displayName ?? username, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + ), + maxLines: 1, ), - maxLines: 2, - overflow: TextOverflow.ellipsis, ), if (displayName != null) ...[ const SizedBox(height: 2), diff --git a/lib/view/pages/settings/widgets/settings_dropdown_tile.dart b/lib/view/pages/settings/widgets/settings_dropdown_tile.dart index 8500856..586219a 100644 --- a/lib/view/pages/settings/widgets/settings_dropdown_tile.dart +++ b/lib/view/pages/settings/widgets/settings_dropdown_tile.dart @@ -32,18 +32,21 @@ class SettingsDropdownTile extends StatelessWidget { trailing: DropdownButton( value: value, icon: const Icon(Icons.arrow_drop_down), + alignment: AlignmentDirectional.centerEnd, + selectedItemBuilder: (context) => options + .map( + (e) => Align( + alignment: AlignmentDirectional.centerEnd, + child: _optionRow(e), + ), + ) + .toList(), items: options .map( (e) => DropdownMenuItem( value: e, enabled: e != value, - child: Row( - children: [ - Icon(optionIcon(e)), - const SizedBox(width: 10), - Text(optionLabel(e)), - ], - ), + child: _optionRow(e), ), ) .toList(), @@ -53,4 +56,19 @@ class SettingsDropdownTile extends StatelessWidget { ), ); } + + Widget _optionRow(T e) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(optionIcon(e)), + const SizedBox(width: 10), + Flexible( + child: Text( + optionLabel(e), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); } From e6f74ca6fecee2303633819bdbec14352e60da69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Sat, 26 Sep 2026 14:34:47 +0200 Subject: [PATCH 10/18] reworked developer tools with diagnostics, storage viewers and release-capable debug overlays --- lib/api/cache_store.dart | 15 +- lib/main.dart | 51 ++- lib/routing/app_routes.dart | 25 +- lib/storage/dev_tools_settings.dart | 28 +- lib/storage/dev_tools_settings.g.dart | 40 +- lib/storage/hydrated_storage_bootstrap.dart | 2 + lib/utils/directory_size.dart | 16 + .../pages/settings/data/default_settings.dart | 2 - lib/view/pages/settings/diagnostics_page.dart | 222 +++++++++++ .../settings/sections/dev_tools_section.dart | 363 ++++++++++++------ lib/widget/avatar_disk_cache.dart | 4 + lib/widget/debug/bloc_storage_view.dart | 217 +++++++++++ lib/widget/debug/cache_view.dart | 126 ++++-- lib/widget/debug/debug_stats_overlay.dart | 205 ++++++++++ lib/widget/debug/debug_tile.dart | 8 +- lib/widget/debug/json_viewer.dart | 220 +++++++++-- pubspec.yaml | 3 + test/utils/directory_size_test.dart | 22 ++ 18 files changed, 1330 insertions(+), 239 deletions(-) create mode 100644 lib/utils/directory_size.dart create mode 100644 lib/view/pages/settings/diagnostics_page.dart create mode 100644 lib/widget/debug/bloc_storage_view.dart create mode 100644 lib/widget/debug/debug_stats_overlay.dart create mode 100644 test/utils/directory_size_test.dart diff --git a/lib/api/cache_store.dart b/lib/api/cache_store.dart index bc97c69..81412f3 100644 --- a/lib/api/cache_store.dart +++ b/lib/api/cache_store.dart @@ -5,6 +5,8 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:path_provider/path_provider.dart'; +import '../utils/directory_size.dart'; + /// One cached response: the raw JSON payload and when it was stored. class CacheEntry { final String json; @@ -140,18 +142,7 @@ class CacheStore { } /// Total size of all entries in bytes. - Future totalSize() async { - try { - final dir = await _directory(); - var sum = 0; - await for (final entity in dir.list()) { - if (entity is File) sum += await entity.length(); - } - return sum; - } on Object { - return 0; - } - } + Future totalSize() async => directorySize(await _directory()); /// Parses the stored file format; public for tests. static CacheEntry? parse(String raw) { diff --git a/lib/main.dart b/lib/main.dart index 2aee13f..6005a26 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,6 +7,7 @@ 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/scheduler.dart' show timeDilation; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; @@ -55,6 +56,7 @@ 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/debug_stats_overlay.dart'; import 'widget/downloads/download_tray.dart'; import 'widget/emergency/emergency_notice_gate.dart'; import 'widget_data/widget_sync.dart'; @@ -384,10 +386,11 @@ class _MainState extends State
{ notificationsEnabled: settings.notificationSettings.enabled, showPerformanceOverlay: settings.devToolsSettings.showPerformanceOverlay, - checkerboardOffscreenLayers: - settings.devToolsSettings.checkerboardOffscreenLayers, - checkerboardRasterCacheImages: - settings.devToolsSettings.checkerboardRasterCacheImages, + showSemanticsDebugger: settings.devToolsSettings.showSemanticsDebugger, + showFrameStats: settings.devToolsSettings.showFrameStats, + showMemoryStats: settings.devToolsSettings.showMemoryStats, + slowAnimations: settings.devToolsSettings.slowAnimations, + textScaleOverride: settings.devToolsSettings.textScaleOverride, ), builder: (context, root) { // Keep the MC dio singleton aligned with the currently selected @@ -403,10 +406,10 @@ class _MainState extends State
{ root.notificationsEnabled, ), ); + timeDilation = root.slowAnimations ? 5.0 : 1.0; return MaterialApp( showPerformanceOverlay: root.showPerformanceOverlay, - checkerboardOffscreenLayers: root.checkerboardOffscreenLayers, - checkerboardRasterCacheImages: root.checkerboardRasterCacheImages, + showSemanticsDebugger: root.showSemanticsDebugger, debugShowCheckedModeBanner: false, navigatorKey: AppRoutes.rootNavigatorKey, // Used by ChatView.didPopNext to reclaim the global ChatBloc. @@ -428,13 +431,28 @@ class _MainState extends State
{ // 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()), - ), + builder: (context, child) { + Widget app = 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()), + ); + final scale = root.textScaleOverride; + if (scale > 0) { + app = MediaQuery.withClampedTextScaling( + minScaleFactor: scale, + maxScaleFactor: scale, + child: app, + ); + } + return DebugStatsOverlay( + showFrameStats: root.showFrameStats, + showMemoryStats: root.showMemoryStats, + child: app, + ); + }, home: EmergencyNoticeGate( child: LoaderOverlay( child: Breaker( @@ -549,6 +567,9 @@ typedef _RootSettings = ({ String mcBaseUrl, bool notificationsEnabled, bool showPerformanceOverlay, - bool checkerboardOffscreenLayers, - bool checkerboardRasterCacheImages, + bool showSemanticsDebugger, + bool showFrameStats, + bool showMemoryStats, + bool slowAnimations, + double textScaleOverride, }); diff --git a/lib/routing/app_routes.dart b/lib/routing/app_routes.dart index 63232c6..7d64606 100644 --- a/lib/routing/app_routes.dart +++ b/lib/routing/app_routes.dart @@ -27,6 +27,7 @@ import '../view/pages/more/feedback/feedback_dialog.dart'; import '../view/pages/more/roomplan/roomplan.dart'; import '../view/pages/more/share/qr_share_view.dart'; import '../view/pages/settings/chat_background_settings_page.dart'; +import '../view/pages/settings/diagnostics_page.dart'; import '../view/pages/settings/modules_settings_page.dart'; import '../view/pages/settings/settings.dart'; import '../view/pages/share_intent/share_chat_picker.dart'; @@ -39,7 +40,9 @@ import '../view/pages/ticker/ticker_page_view.dart'; import '../view/pages/timetable/custom_events/custom_events_view.dart'; import '../view/pages/timetable/subject_colors/subject_colors_view.dart'; import '../widget/avatar_crop_page.dart'; +import '../widget/debug/bloc_storage_view.dart'; import '../widget/debug/cache_view.dart'; +import '../widget/debug/json_viewer.dart'; import '../widget/file_viewer.dart'; import '../widget/large_profile_picture_view.dart'; import '../widget/user_avatar.dart'; @@ -285,8 +288,26 @@ class AppRoutes { pushScreen(context, withNavBar: false, screen: const FeedbackDialog()); } - static void openCacheView(BuildContext context) { - pushScreen(context, withNavBar: false, screen: const CacheView()); + static Future openCacheView(BuildContext context) => + pushScreen(context, withNavBar: false, screen: const CacheView()); + + static Future openBlocStorageView(BuildContext context) => + pushScreen(context, withNavBar: false, screen: const BlocStorageView()); + + static void openDiagnostics(BuildContext context) { + pushScreen(context, withNavBar: false, screen: const DiagnosticsPage()); + } + + static void openJsonViewer( + BuildContext context, + Object? data, { + String? title, + }) { + pushScreen( + context, + withNavBar: false, + screen: JsonViewer(data: data, title: title ?? 'Rohdaten'), + ); } static void openRoomplan(BuildContext context) { diff --git a/lib/storage/dev_tools_settings.dart b/lib/storage/dev_tools_settings.dart index a50f540..b9d8be2 100644 --- a/lib/storage/dev_tools_settings.dart +++ b/lib/storage/dev_tools_settings.dart @@ -8,8 +8,22 @@ enum MarianumConnectEndpoint { live, beta, custom } @JsonSerializable() class DevToolsSettings { bool showPerformanceOverlay; - bool checkerboardOffscreenLayers; - bool checkerboardRasterCacheImages; + + @JsonKey(defaultValue: false) + bool showFrameStats; + + @JsonKey(defaultValue: false) + bool showMemoryStats; + + @JsonKey(defaultValue: false) + bool showSemanticsDebugger; + + @JsonKey(defaultValue: false) + bool slowAnimations; + + /// Forced text scale factor; `0` follows the system setting. + @JsonKey(defaultValue: 0) + double textScaleOverride; @JsonKey(defaultValue: MarianumConnectEndpoint.live) MarianumConnectEndpoint marianumConnectEndpoint; @@ -24,8 +38,11 @@ class DevToolsSettings { DevToolsSettings({ required this.showPerformanceOverlay, - required this.checkerboardOffscreenLayers, - required this.checkerboardRasterCacheImages, + this.showFrameStats = false, + this.showMemoryStats = false, + this.showSemanticsDebugger = false, + this.slowAnimations = false, + this.textScaleOverride = 0, this.marianumConnectEndpoint = MarianumConnectEndpoint.live, this.marianumConnectCustomUrl = '', this.emergencyNoticeUrl = '', @@ -51,7 +68,8 @@ class DevToolsSettings { /// Compiled-in source for the backend-independent emergency notice. Must live /// on infrastructure that is reachable even when MarianumConnect is down. - static const String emergencyNoticeDefaultUrl = 'https://www.marianum-fulda.de/~aushang/marMobile.override'; + static const String emergencyNoticeDefaultUrl = + 'https://www.marianum-fulda.de/~aushang/marMobile.override'; String? resolveEmergencyNoticeUrl() => sanitizeCustomUrl(emergencyNoticeUrl) ?? diff --git a/lib/storage/dev_tools_settings.g.dart b/lib/storage/dev_tools_settings.g.dart index 75540e6..f3f4972 100644 --- a/lib/storage/dev_tools_settings.g.dart +++ b/lib/storage/dev_tools_settings.g.dart @@ -6,27 +6,33 @@ part of 'dev_tools_settings.dart'; // JsonSerializableGenerator // ************************************************************************** -DevToolsSettings _$DevToolsSettingsFromJson( - Map json, -) => DevToolsSettings( - showPerformanceOverlay: json['showPerformanceOverlay'] as bool, - checkerboardOffscreenLayers: json['checkerboardOffscreenLayers'] as bool, - checkerboardRasterCacheImages: json['checkerboardRasterCacheImages'] as bool, - marianumConnectEndpoint: - $enumDecodeNullable( - _$MarianumConnectEndpointEnumMap, - json['marianumConnectEndpoint'], - ) ?? - MarianumConnectEndpoint.live, - marianumConnectCustomUrl: json['marianumConnectCustomUrl'] as String? ?? '', - emergencyNoticeUrl: json['emergencyNoticeUrl'] as String? ?? '', -); +DevToolsSettings _$DevToolsSettingsFromJson(Map json) => + DevToolsSettings( + showPerformanceOverlay: json['showPerformanceOverlay'] as bool, + showFrameStats: json['showFrameStats'] as bool? ?? false, + showMemoryStats: json['showMemoryStats'] as bool? ?? false, + showSemanticsDebugger: json['showSemanticsDebugger'] as bool? ?? false, + slowAnimations: json['slowAnimations'] as bool? ?? false, + textScaleOverride: (json['textScaleOverride'] as num?)?.toDouble() ?? 0, + marianumConnectEndpoint: + $enumDecodeNullable( + _$MarianumConnectEndpointEnumMap, + json['marianumConnectEndpoint'], + ) ?? + MarianumConnectEndpoint.live, + marianumConnectCustomUrl: + json['marianumConnectCustomUrl'] as String? ?? '', + emergencyNoticeUrl: json['emergencyNoticeUrl'] as String? ?? '', + ); Map _$DevToolsSettingsToJson(DevToolsSettings instance) => { 'showPerformanceOverlay': instance.showPerformanceOverlay, - 'checkerboardOffscreenLayers': instance.checkerboardOffscreenLayers, - 'checkerboardRasterCacheImages': instance.checkerboardRasterCacheImages, + 'showFrameStats': instance.showFrameStats, + 'showMemoryStats': instance.showMemoryStats, + 'showSemanticsDebugger': instance.showSemanticsDebugger, + 'slowAnimations': instance.slowAnimations, + 'textScaleOverride': instance.textScaleOverride, 'marianumConnectEndpoint': _$MarianumConnectEndpointEnumMap[instance.marianumConnectEndpoint]!, 'marianumConnectCustomUrl': instance.marianumConnectCustomUrl, diff --git a/lib/storage/hydrated_storage_bootstrap.dart b/lib/storage/hydrated_storage_bootstrap.dart index 4a44969..08369b3 100644 --- a/lib/storage/hydrated_storage_bootstrap.dart +++ b/lib/storage/hydrated_storage_bootstrap.dart @@ -37,6 +37,8 @@ Future _deleteHydratedBox(String directoryPath) async { class InMemoryStorage implements Storage { final Map _values = {}; + Iterable get keys => _values.keys; + @override dynamic read(String key) => _values[key]; diff --git a/lib/utils/directory_size.dart b/lib/utils/directory_size.dart new file mode 100644 index 0000000..b71aa88 --- /dev/null +++ b/lib/utils/directory_size.dart @@ -0,0 +1,16 @@ +import 'dart:io'; + +/// Total byte size of all files below [dir]; `0` when it doesn't exist or +/// can't be listed. +Future directorySize(Directory dir) async { + var sum = 0; + try { + if (!dir.existsSync()) return 0; + await for (final entity in dir.list(recursive: true, followLinks: false)) { + if (entity is File) sum += await entity.length(); + } + } on FileSystemException { + // Files can vanish mid-walk (cache pruning); report what was counted. + } + return sum; +} diff --git a/lib/view/pages/settings/data/default_settings.dart b/lib/view/pages/settings/data/default_settings.dart index 4a54706..670796f 100644 --- a/lib/view/pages/settings/data/default_settings.dart +++ b/lib/view/pages/settings/data/default_settings.dart @@ -70,8 +70,6 @@ class DefaultSettings { fileViewSettings: FileViewSettings(alwaysOpenExternally: Platform.isIOS), notificationSettings: NotificationSettings(enabled: true), devToolsSettings: DevToolsSettings( - checkerboardOffscreenLayers: false, - checkerboardRasterCacheImages: false, showPerformanceOverlay: false, marianumConnectEndpoint: MarianumConnectEndpoint.live, marianumConnectCustomUrl: '', diff --git a/lib/view/pages/settings/diagnostics_page.dart b/lib/view/pages/settings/diagnostics_page.dart new file mode 100644 index 0000000..f11bdb6 --- /dev/null +++ b/lib/view/pages/settings/diagnostics_page.dart @@ -0,0 +1,222 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +import '../../../api/marianumconnect/auth/device_token_name.dart'; +import '../../../api/marianumconnect/auth/token_storage.dart'; +import '../../../api/marianumconnect/marianumconnect_endpoint.dart'; +import '../../../api/marianumconnect/queries/telemetry_heartbeat/telemetry_device_id.dart'; +import '../../../background/widget_background_task.dart'; +import '../../../extensions/date_time.dart'; +import '../../../model/account_data.dart'; +import '../../../model/endpoint_data.dart'; +import '../../../push/push_registration_store.dart'; +import '../../../push/push_registration_type.dart'; +import '../../../state/app/modules/account/bloc/account_bloc.dart'; +import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; +import '../../../utils/clipboard_helper.dart'; +import '../../../widget/app_progress_indicator.dart'; +import '../../../widget/async_action_button.dart'; +import '../../../widget/centered_leading.dart'; +import '../../../widget_data/widget_sync.dart'; +import 'widgets/push_status_sheet.dart'; + +typedef _Row = ({String label, String value}); +typedef _Section = ({String title, List<_Row> rows}); + +/// Read-only snapshot of build, endpoint, session, push and widget state for +/// bug reports. Secrets (passwords, bearer token) are never shown. +class DiagnosticsPage extends StatefulWidget { + const DiagnosticsPage({super.key}); + + @override + State createState() => _DiagnosticsPageState(); +} + +class _DiagnosticsPageState extends State { + late Future> _sections = _collect(); + + Future> _collect() async { + final settings = context.read().val(); + final accountStatus = context.read().state.status; + final account = AccountData(); + const tokens = MarianumConnectTokenStorage(); + final push = PushRegistrationStore(); + + Future safe(Future Function() read) async { + try { + final value = await read().timeout(const Duration(seconds: 5)); + return switch (value) { + null => '–', + bool() => value ? 'ja' : 'nein', + DateTime() => '${value.formatDateTime()} (${value.formatRelative()})', + _ => '$value', + }; + } on Object catch (e) { + return 'Fehler: $e'; + } + } + + Future> rows(Map Function()> reads) => + Future.wait( + reads.entries.map( + (e) async => (label: e.key, value: await safe(e.value)), + ), + ); + + final info = await PackageInfo.fromPlatform(); + return [ + ( + title: 'App', + rows: await rows({ + 'Version': () async => '${info.version} (${info.buildNumber})', + 'Paket': () async => info.packageName, + 'Build-Modus': () async => kReleaseMode + ? 'Release' + : kProfileMode + ? 'Profile' + : 'Debug', + 'Plattform': () async => + '${Platform.operatingSystem} ${Platform.operatingSystemVersion}', + 'Gerät': DeviceTokenName.resolve, + 'Dart': () async => Platform.version.split(' ').first, + }), + ), + ( + title: 'Server', + rows: await rows({ + 'Marianum Connect': () async => MarianumConnectEndpoint.current(), + 'Nextcloud': () async => + 'https://${EndpointData().nextcloud().full()}', + 'Notfall-Nachricht': () async => + settings.devToolsSettings.resolveEmergencyNoticeUrl(), + }), + ), + ( + title: 'Sitzung', + rows: await rows({ + 'Status': () async => accountStatus.name, + 'Benutzer': () async => + account.isPopulated() ? account.getUsername() : null, + 'Demo-Modus': () async => account.isDemo, + 'Login Flow v2': () async => account.usesLoginFlow, + 'App-Passwort (Dateien)': () async => account.hasAppPassword(), + 'App-Passwort (Talk)': () async => account.hasAppPasswordTalk(), + 'Session-Epoche': () async => account.sessionEpoch, + 'MC-Token-ID': tokens.readTokenId, + 'MC-Token läuft ab': tokens.readExpiresAt, + }), + ), + ( + title: 'Push', + rows: await rows({ + 'Benachrichtigungen': () async => + settings.notificationSettings.enabled, + 'FCM-Token': FirebaseMessaging.instance.getToken, + for (final type in PushRegistrationType.values) ...{ + 'Registriert (${type.name})': () => push.isRegistered(type), + 'Letzte Registrierung (${type.name})': () => + push.lastRegistrationAt(type), + 'Letzter Fehler (${type.name})': () => + push.lastRegistrationError(type), + }, + }), + ), + ( + title: 'Sonstiges', + rows: await rows({ + 'Telemetrie-Geräte-ID': TelemetryDeviceId.resolve, + 'Widget zuletzt abgerufen': WidgetSync.getFetchedAt, + }), + ), + ]; + } + + String _asText(List<_Section> sections) => [ + for (final s in sections) ...[ + '## ${s.title}', + for (final r in s.rows) '${r.label}: ${r.value}', + '', + ], + ].join('\n'); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: const Text('Diagnose'), + actions: [ + IconButton( + tooltip: 'Neu laden', + icon: const Icon(Icons.refresh), + onPressed: () => setState(() { + _sections = _collect(); + }), + ), + FutureBuilder( + future: _sections, + builder: (context, snapshot) => IconButton( + tooltip: 'Alles kopieren', + icon: const Icon(Icons.copy_all_outlined), + onPressed: snapshot.hasData + ? () => copyToClipboard(context, _asText(snapshot.data!)) + : null, + ), + ), + ], + ), + body: FutureBuilder( + future: _sections, + builder: (context, snapshot) { + final sections = snapshot.data; + if (sections == null) { + return const Center(child: AppProgressIndicator.large()); + } + return ListView( + children: [ + for (final section in sections) ...[ + ListTile( + dense: true, + title: Text( + section.title, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ), + for (final row in section.rows) + ListTile( + dense: true, + title: Text(row.label), + subtitle: SelectableText(row.value), + onLongPress: () => copyToClipboard(context, row.value), + ), + if (section.title == 'Push') + ListTile( + leading: const CenteredLeading( + Icon(Icons.notifications_active_outlined), + ), + title: const Text('Push-Status prüfen'), + trailing: const Icon(Icons.arrow_right), + onTap: () => showPushStatusSheet(context), + ), + if (section.title == 'Sonstiges') + AsyncListTile( + leading: const CenteredLeading(Icon(Icons.widgets_outlined)), + title: const Text('Widget jetzt aktualisieren'), + closeOnSuccess: false, + onPressed: WidgetBackgroundTask.requestImmediateRefresh, + ), + const Divider(), + ], + ], + ); + }, + ), + ); +} diff --git a/lib/view/pages/settings/sections/dev_tools_section.dart b/lib/view/pages/settings/sections/dev_tools_section.dart index ac8963d..0066644 100644 --- a/lib/view/pages/settings/sections/dev_tools_section.dart +++ b/lib/view/pages/settings/sections/dev_tools_section.dart @@ -1,19 +1,30 @@ +import 'dart:convert'; +import 'dart:io'; + import 'package:filesize/filesize.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:hydrated_bloc/hydrated_bloc.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:path_provider/path_provider.dart'; import '../../../../api/cache_store.dart'; +import '../../../../push/chat_thread_store.dart'; +import '../../../../push/nid_store.dart'; import '../../../../routing/app_routes.dart'; import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../../storage/dev_tools_settings.dart'; import '../../../../storage/settings.dart' as model; +import '../../../../utils/directory_size.dart'; +import '../../../../utils/downloads/download_manager.dart'; +import '../../../../widget/avatar_disk_cache.dart'; import '../../../../widget/centered_leading.dart'; import '../../../../widget/confirm_dialog.dart'; -import '../../../../widget/debug/json_viewer.dart'; +import '../../../../widget/debug/bloc_storage_view.dart'; import '../../../../widget/details_bottom_sheet.dart'; +import '../../../../widget/user_avatar.dart'; import '../widgets/endpoint_picker.dart'; import '../widgets/settings_checkbox_tile.dart'; +import '../widgets/settings_dropdown_tile.dart'; class DevToolsSection extends StatefulWidget { final SettingsCubit settings; @@ -24,15 +35,19 @@ class DevToolsSection extends StatefulWidget { } class _DevToolsSectionState extends State { - // Kept across rebuilds: the size walk lists the whole cache directory. - Future? _cacheSize; - @override Widget build(BuildContext context) => Column( children: [ + ListTile( + leading: const CenteredLeading(Icon(Icons.monitor_heart_outlined)), + title: const Text('Diagnose'), + subtitle: const Text('Version, Server, Sitzung, Push'), + trailing: const Icon(Icons.arrow_right), + onTap: () => AppRoutes.openDiagnostics(context), + ), ListTile( leading: const CenteredLeading(Icon(Icons.speed_outlined)), - title: const Text('Performance overlays'), + title: const Text('Debug-Overlays'), trailing: const Icon(Icons.arrow_right), onTap: () { showDetailsBottomSheet( @@ -42,35 +57,57 @@ class _DevToolsSectionState extends State { bloc: widget.settings, builder: (_, _) { final dev = widget.settings.val().devToolsSettings; + DevToolsSettings write() => + widget.settings.val(write: true).devToolsSettings; return Column( mainAxisSize: MainAxisSize.min, children: [ SettingsCheckboxTile( icon: Icons.auto_graph_outlined, - title: 'Performance graph', + title: 'Performance-Graph', value: dev.showPerformanceOverlay, - onChanged: (e) => widget.settings - .val(write: true) - .devToolsSettings - .showPerformanceOverlay = e, + onChanged: (e) => write().showPerformanceOverlay = e, ), SettingsCheckboxTile( - icon: Icons.screen_search_desktop_outlined, - title: 'Indicate offscreen layers', - value: dev.checkerboardOffscreenLayers, - onChanged: (e) => widget.settings - .val(write: true) - .devToolsSettings - .checkerboardOffscreenLayers = e, + icon: Icons.timer_outlined, + title: 'Frame-Statistik', + subtitle: 'Build-/Raster-Zeiten und Jank-Zähler', + value: dev.showFrameStats, + onChanged: (e) => write().showFrameStats = e, ), SettingsCheckboxTile( - icon: Icons.imagesearch_roller_outlined, - title: 'Indicate raster cache images', - value: dev.checkerboardRasterCacheImages, - onChanged: (e) => widget.settings - .val(write: true) - .devToolsSettings - .checkerboardRasterCacheImages = e, + icon: Icons.memory_outlined, + title: 'Speicher-Statistik', + subtitle: 'Prozess-RAM und Bild-Cache', + value: dev.showMemoryStats, + onChanged: (e) => write().showMemoryStats = e, + ), + SettingsCheckboxTile( + icon: Icons.slow_motion_video_outlined, + title: 'Langsame Animationen', + subtitle: '5× verlangsamt', + value: dev.slowAnimations, + onChanged: (e) => write().slowAnimations = e, + ), + SettingsCheckboxTile( + icon: Icons.accessibility_new_outlined, + title: 'Semantics-Debugger', + subtitle: 'Zeigt die Screenreader-Sicht', + value: dev.showSemanticsDebugger, + onChanged: (e) => write().showSemanticsDebugger = e, + ), + SettingsDropdownTile( + icon: Icons.format_size_outlined, + title: 'Textgröße', + value: dev.textScaleOverride, + options: const [0, 0.85, 1, 1.3, 1.6, 2], + optionIcon: (e) => e == 0 + ? Icons.phone_android_outlined + : Icons.text_fields_outlined, + optionLabel: (e) => e == 0 + ? 'System' + : '${e == e.roundToDouble() ? e.toInt() : e}×', + onChanged: (e) => write().textScaleOverride = e, ), ], ); @@ -102,99 +139,204 @@ class _DevToolsSectionState extends State { .trim(); return ListTile( leading: const CenteredLeading(Icon(Icons.emergency_outlined)), - title: const Text('Notfall-Nachricht (Quelle)'), - subtitle: Text( - override.isEmpty - ? 'Standardquelle (im Code hinterlegt)' - : override, - ), + title: const Text('Notfall-Nachrichtenquelle'), + subtitle: Text(override.isEmpty ? 'Standardquelle' : override), trailing: const Icon(Icons.arrow_right), - onTap: () => _EmergencyNoticeUrlEditor.show(context, widget.settings), + onTap: () => + _EmergencyNoticeUrlEditor.show(context, widget.settings), ); }, ), - ListTile( - leading: const CenteredLeading(Icon(Icons.image_outlined)), - title: const Text('Thumb-storage'), - subtitle: Text( - 'etwa ${filesize(PaintingBinding.instance.imageCache.currentSizeBytes)}\nLange tippen um zu löschen', + const Divider(), + _StorageTile( + icon: Icons.settings_applications_outlined, + title: 'Einstellungen', + describe: () async => filesize( + utf8.encode(jsonEncode(widget.settings.val().toJson())).length, ), - onLongPress: () { - ConfirmDialog( - title: 'Thumbs cache löschen', - content: 'Alle zwischengespeicherten Bilder werden gelöscht.', - confirmButton: 'Unwiederruflich löschen', - onConfirm: () => PaintingBinding.instance.imageCache.clear(), - ).asDialog(context); - }, + onOpen: (_) async => AppRoutes.openJsonViewer( + context, + widget.settings.val().toJson(), + title: 'Einstellungen', + ), + clearTitle: 'Einstellungen zurücksetzen', + clearContent: + 'Alle Einstellungen gehen verloren! Accountdaten sowie App-Daten ' + 'sind nicht betroffen.', + onClear: widget.settings.reset, ), - ListTile( - leading: const CenteredLeading( - Icon(Icons.settings_applications_outlined), - ), - title: const Text('Settings-storage JSON dump'), - subtitle: Text( - 'etwa ${filesize(widget.settings.val().toJson().toString().length * 8)}\nLange tippen um zu löschen', - ), - onTap: () { - JsonViewer.asDialog(context, widget.settings.val().toJson()); + _StorageTile( + icon: Icons.account_tree_outlined, + title: 'BLoC-Speicher', + describe: () async { + final keys = BlocStorageView.keys(); + return keys == null ? 'nicht auflistbar' : '${keys.length} Einträge'; }, - onLongPress: () { - ConfirmDialog( - title: 'Einstellungen löschen', - content: - 'Alle Einstellungen gehen verloren! Accountdaten sowie App-Daten sind nicht betroffen.', - confirmButton: 'Unwiederruflich Löschen', - onConfirm: () { - context.read().reset(); - }, - ).asDialog(context); - }, - trailing: const Icon(Icons.arrow_right), + onOpen: AppRoutes.openBlocStorageView, ), - ListTile( - leading: const CenteredLeading(Icon(Icons.data_object)), - title: const Text('Cache-storage JSON dump'), - subtitle: FutureBuilder( - future: _cacheSize ??= CacheStore.instance.totalSize(), - builder: (context, snapshot) => Text( - "etwa ${snapshot.hasError - ? "?" - : snapshot.hasData - ? filesize(snapshot.data) - : "..."}\nLange tippen um zu löschen", + _StorageTile( + icon: Icons.data_object, + title: 'Request-Cache', + describe: () async => filesize(await CacheStore.instance.totalSize()), + onOpen: AppRoutes.openCacheView, + ), + _StorageTile( + icon: Icons.image_outlined, + title: 'Bild-Speicher (RAM)', + describe: () async { + final cache = PaintingBinding.instance.imageCache; + return '${cache.currentSize} Bilder · ' + '${filesize(cache.currentSizeBytes)} von ' + '${filesize(cache.maximumSizeBytes)}'; + }, + clearTitle: 'Bild-Speicher leeren', + clearContent: + 'Alle dekodierten Bilder im Arbeitsspeicher werden verworfen.', + onClear: () async => PaintingBinding.instance.imageCache.clear(), + ), + _StorageTile( + icon: Icons.photo_library_outlined, + title: 'Netzwerk-Bilder', + describe: () async => filesize( + await directorySize( + Directory( + '${(await getTemporaryDirectory()).path}/${DefaultCacheManager.key}', + ), ), ), - onTap: () => AppRoutes.openCacheView(context), - onLongPress: () { - ConfirmDialog( - title: 'App-Cache löschen', - content: - 'Alle cache Einträge werden gelöscht. Der Cache wird bei Nutzung der App automatisch erneut aufgebaut', - confirmButton: 'Unwiederruflich löschen', - onConfirm: () => CacheStore.instance.clear().then( - (value) => setState(() => _cacheSize = null), - ), - ).asDialog(context); - }, - trailing: const Icon(Icons.arrow_right), + clearTitle: 'Netzwerk-Bilder löschen', + clearContent: + 'Heruntergeladene Chat- und Vorschaubilder werden gelöscht und bei ' + 'Bedarf neu geladen.', + onClear: DefaultCacheManager().emptyCache, ), - ListTile( - leading: const CenteredLeading(Icon(Icons.data_object)), - title: const Text('BLOC-storage state cache'), - subtitle: const Text('Lange tippen um zu löschen'), - onLongPress: () { - ConfirmDialog( - title: 'BLOC-Cache löschen', - content: - 'Alle cache Einträge werden gelöscht. Der Cache wird bei Nutzung der App automatisch erneut aufgebaut', - confirmButton: 'Unwiederruflich löschen', - onConfirm: () => HydratedBloc.storage.clear(), - ).asDialog(context); + _StorageTile( + icon: Icons.account_circle_outlined, + title: 'Avatar-Cache', + describe: () async => + filesize(await AvatarDiskCache.instance.totalSize()), + clearTitle: 'Avatar-Cache löschen', + clearContent: 'Profilbilder werden bei Bedarf neu geladen.', + onClear: () async { + invalidateAvatarCache(); + await AvatarDiskCache.instance.clear(); + }, + ), + _StorageTile( + icon: Icons.notifications_outlined, + title: 'Push-Zuordnungen', + describe: () async => + '${(await NidStore().all()).length} Benachrichtigungen zugeordnet', + clearTitle: 'Push-Zuordnungen löschen', + clearContent: + 'Die Zuordnung angezeigter Benachrichtigungen zu Chats und der ' + 'gruppierte Chat-Verlauf gehen verloren. Bereits angezeigte ' + 'Benachrichtigungen werden danach nicht mehr automatisch entfernt.', + onClear: () async { + await NidStore().clear(); + await ChatThreadStore().clearAll(); + }, + ), + _StorageTile( + icon: Icons.download_outlined, + title: 'Downloads', + describe: () async => + filesize(await directorySize(await _downloadsDir())), + clearTitle: 'Downloads löschen', + clearContent: + 'Laufende Downloads werden abgebrochen und heruntergeladene ' + 'Dateien gelöscht.', + onClear: () async { + DownloadManager.instance.clearAll(); + final dir = await _downloadsDir(); + if (!dir.existsSync()) return; + // Cancelled tasks can still be flushing into the folder, so deleting + // it as a whole races them; whatever survives shows up in the size. + await for (final entity in dir.list()) { + try { + await entity.delete(recursive: true); + } on FileSystemException { + // Retried on the next clear. + } + } }, ), ], ); + + static Future _downloadsDir() async => + Directory('${(await getTemporaryDirectory()).path}/downloads'); +} + +/// Storage row: tap opens a detail view ([onOpen]), the trailing button clears +/// it after confirmation ([onClear]). The subtitle re-evaluates [describe] +/// after either. +class _StorageTile extends StatefulWidget { + final IconData icon; + final String title; + final Future Function() describe; + final Future Function(BuildContext context)? onOpen; + final String? clearTitle; + final String? clearContent; + final Future Function()? onClear; + + const _StorageTile({ + required this.icon, + required this.title, + required this.describe, + this.onOpen, + this.clearTitle, + this.clearContent, + this.onClear, + }); + + @override + State<_StorageTile> createState() => _StorageTileState(); +} + +class _StorageTileState extends State<_StorageTile> { + late Future _description = widget.describe(); + + void _refresh() => setState(() { + _description = widget.describe(); + }); + + @override + Widget build(BuildContext context) { + final onOpen = widget.onOpen; + final onClear = widget.onClear; + return ListTile( + leading: CenteredLeading(Icon(widget.icon)), + title: Text(widget.title), + subtitle: FutureBuilder( + future: _description, + builder: (context, snapshot) => + Text(snapshot.hasError ? '?' : snapshot.data ?? '…'), + ), + onTap: onOpen == null + ? null + : () async { + await onOpen(context); + if (mounted) _refresh(); + }, + trailing: onClear == null + ? const Icon(Icons.arrow_right) + : IconButton( + tooltip: 'Löschen', + icon: const Icon(Icons.delete_outline), + onPressed: () => ConfirmDialog( + title: widget.clearTitle ?? '${widget.title} löschen', + content: widget.clearContent ?? '', + confirmButton: 'Löschen', + errorBuilder: (e) => '$e', + onConfirmAsync: () async { + await onClear(); + if (mounted) _refresh(); + }, + ).asDialog(context), + ), + ); + } } /// Bottom-sheet editor for the backend-independent emergency-notice override @@ -206,7 +348,7 @@ class _EmergencyNoticeUrlEditor extends StatefulWidget { static void show(BuildContext context, SettingsCubit settings) { showDetailsBottomSheet( context, - header: const ListTile(title: Text('Notfall-Nachricht (Quelle)')), + header: const ListTile(title: Text('Notfall-Nachrichtenquelle')), children: (sheetCtx) => [_EmergencyNoticeUrlEditor(settings: settings)], ); } @@ -237,8 +379,7 @@ class _EmergencyNoticeUrlEditorState extends State<_EmergencyNoticeUrlEditor> { void _save() { final raw = _controller.text.trim(); // Empty is valid: it clears the override and falls back to the default. - if (raw.isNotEmpty && - DevToolsSettings.sanitizeCustomUrl(raw) == null) { + if (raw.isNotEmpty && DevToolsSettings.sanitizeCustomUrl(raw) == null) { setState( () => _error = DevToolsSettings.allowsHttpCustomEndpoint ? 'Ungültige URL (http(s)://host[:port]/...)' @@ -257,19 +398,11 @@ class _EmergencyNoticeUrlEditorState extends State<_EmergencyNoticeUrlEditor> { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const Padding( - padding: EdgeInsets.only(bottom: 12), - child: Text( - 'Überschreibt die im Code hinterlegte Standardquelle. Leer ' - 'lassen, um die Standardquelle zu verwenden.', - style: TextStyle(fontSize: 12), - ), - ), TextField( controller: _controller, keyboardType: TextInputType.url, decoration: InputDecoration( - labelText: 'https://...', + labelText: 'Leer für Standard', errorText: _error, ), onChanged: (_) { diff --git a/lib/widget/avatar_disk_cache.dart b/lib/widget/avatar_disk_cache.dart index 14801b7..2d038f4 100644 --- a/lib/widget/avatar_disk_cache.dart +++ b/lib/widget/avatar_disk_cache.dart @@ -5,6 +5,8 @@ import 'dart:typed_data'; import 'package:path_provider/path_provider.dart'; +import '../utils/directory_size.dart'; + /// Persistent disk cache for the in-app [UserAvatar] widget. /// /// The widget keeps a session-scoped in-memory LRU; this store survives app @@ -156,6 +158,8 @@ class AvatarDiskCache { } } + Future totalSize() async => directorySize(await _directory()); + /// Wipes the whole cache — used by the argument-less `invalidateAvatarCache` /// (e.g. on logout). Future clear() async { diff --git a/lib/widget/debug/bloc_storage_view.dart b/lib/widget/debug/bloc_storage_view.dart new file mode 100644 index 0000000..e1a302f --- /dev/null +++ b/lib/widget/debug/bloc_storage_view.dart @@ -0,0 +1,217 @@ +import 'dart:convert'; + +import 'package:filesize/filesize.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:hydrated_bloc/hydrated_bloc.dart'; + +import '../../extensions/date_time.dart'; +import '../../routing/app_routes.dart'; +import '../../state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.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/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart'; +import '../../state/app/modules/settings/bloc/settings_cubit.dart'; +import '../../state/app/modules/timetable/bloc/timetable_bloc.dart'; +import '../../storage/hydrated_storage_bootstrap.dart'; +import '../confirm_dialog.dart'; +import '../placeholder_view.dart'; +import 'json_viewer.dart'; + +/// Lists every key persisted through [HydratedBloc.storage]. +class BlocStorageView extends StatefulWidget { + const BlocStorageView({super.key}); + + /// All persisted keys; `null` when the storage can't be enumerated. + static List? keys() { + final storage = HydratedBloc.storage; + if (storage is InMemoryStorage) return storage.keys.toList()..sort(); + try { + // hydrated_bloc exposes no key listing; the Hive instance is the only + // way in. + // ignore: invalid_use_of_visible_for_testing_member + final box = HydratedStorage.hive.box('hydrated_box'); + return box.keys.map((k) => '$k').toList()..sort(); + } on Object { + return null; + } + } + + @override + State createState() => _BlocStorageViewState(); +} + +class _Entry { + final String key; + final Object? value; + final int size; + final DateTime? lastFetch; + + _Entry(this.key, this.value) + : size = utf8.encode(jsonEncode(JsonViewer.toEncodable(value))).length, + lastFetch = _lastFetch(value); + + static DateTime? _lastFetch(Object? value) { + if (value is! Map) return null; + final meta = value['meta']; + if (meta is! Map || meta['timestamp'] is! int) return null; + final ts = meta['timestamp'] as int; + return ts > 0 ? DateTime.fromMillisecondsSinceEpoch(ts) : null; + } +} + +class _BlocStorageViewState extends State { + List<_Entry>? _entries; + + @override + void initState() { + super.initState(); + _load(); + } + + void _load() { + final keys = BlocStorageView.keys(); + setState( + () => _entries = keys + ?.map((k) => _Entry(k, HydratedBloc.storage.read(k))) + .toList(), + ); + } + + /// Blocs that live for the whole app session would simply re-persist their + /// in-memory state, so those are reset through the bloc itself. + Future Function()? _liveReset(String key) { + Future Function() loadable( + LoadableHydratedBloc bloc, + ) => () async { + await bloc.reset(); + bloc.retry(); + }; + return switch (key) { + 'TimetableBloc' => loadable(context.read()), + 'ChatListBloc' => loadable(context.read()), + 'ChatBloc' => loadable(context.read()), + 'BreakerBloc' => loadable(context.read()), + 'CapabilitiesCubit' => () async { + final cubit = context.read(); + await cubit.reset(); + await cubit.load(); + }, + 'NextcloudCapabilitiesCubit' => () async { + final cubit = context.read(); + await cubit.reset(); + await cubit.load(); + }, + 'SettingsCubit' => context.read().reset, + _ => null, + }; + } + + Future _delete(String key) async { + final reset = _liveReset(key); + if (reset != null) { + await reset(); + } else { + await HydratedBloc.storage.delete(key); + } + } + + void _confirmDelete(_Entry entry) { + final isSettings = entry.key == 'SettingsCubit'; + ConfirmDialog( + title: isSettings ? 'Einstellungen zurücksetzen' : 'Eintrag löschen', + content: isSettings + ? 'Alle Einstellungen werden auf die Standardwerte zurückgesetzt.' + : '„${entry.key}“ wird gelöscht und bei Bedarf neu geladen.', + confirmButton: 'Löschen', + errorBuilder: (e) => '$e', + onConfirmAsync: () async { + await _delete(entry.key); + if (mounted) _load(); + }, + ).asDialog(context); + } + + void _confirmDeleteAll() { + ConfirmDialog( + title: 'BLoC-Speicher leeren', + content: + 'Alle gespeicherten Modul-Zustände außer den Einstellungen werden ' + 'gelöscht und bei Bedarf neu geladen.', + confirmButton: 'Alles löschen', + errorBuilder: (e) => '$e', + onConfirmAsync: () async { + for (final entry in [...?_entries]) { + if (entry.key == 'SettingsCubit') continue; + await _delete(entry.key); + } + if (mounted) _load(); + }, + ).asDialog(context); + } + + @override + Widget build(BuildContext context) { + final entries = _entries; + return Scaffold( + appBar: AppBar( + title: const Text('BLoC-Speicher'), + actions: [ + IconButton( + tooltip: 'Neu laden', + icon: const Icon(Icons.refresh), + onPressed: _load, + ), + if (entries != null && entries.isNotEmpty) + IconButton( + tooltip: 'Alles löschen', + icon: const Icon(Icons.delete_sweep_outlined), + onPressed: _confirmDeleteAll, + ), + ], + ), + body: entries == null + ? const PlaceholderView( + icon: Icons.error_outline, + text: 'Speicher lässt sich nicht auflisten', + ) + : entries.isEmpty + ? const PlaceholderView( + icon: Icons.hourglass_empty, + text: 'Keine Daten', + ) + : ListView.builder( + itemCount: entries.length, + itemBuilder: (context, index) { + final entry = entries[index]; + final live = _liveReset(entry.key) != null; + return ListTile( + leading: Icon( + live ? Icons.bolt_outlined : Icons.inventory_2_outlined, + ), + title: Text(entry.key), + subtitle: Text( + [ + filesize(entry.size), + if (entry.lastFetch != null) + 'geladen ${entry.lastFetch!.formatRelative()}', + ].join(' · '), + ), + onTap: () => AppRoutes.openJsonViewer( + context, + entry.value, + title: entry.key, + ), + trailing: IconButton( + tooltip: 'Löschen', + icon: const Icon(Icons.delete_outline), + onPressed: () => _confirmDelete(entry), + ), + ); + }, + ), + ); + } +} diff --git a/lib/widget/debug/cache_view.dart b/lib/widget/debug/cache_view.dart index 75cd750..4a98c61 100644 --- a/lib/widget/debug/cache_view.dart +++ b/lib/widget/debug/cache_view.dart @@ -1,62 +1,116 @@ import 'dart:convert'; + import 'package:filesize/filesize.dart'; import 'package:flutter/material.dart'; -import 'package:jiffy/jiffy.dart'; -import '../../../widget/placeholder_view.dart'; import '../../api/cache_store.dart'; +import '../../extensions/date_time.dart'; +import '../../routing/app_routes.dart'; import '../app_progress_indicator.dart'; -import 'json_viewer.dart'; +import '../confirm_dialog.dart'; +import '../placeholder_view.dart'; +/// Lists the [CacheStore] entries (one file per request key), newest first. class CacheView extends StatefulWidget { const CacheView({super.key}); @override State createState() => _CacheViewState(); - } class _CacheViewState extends State { - late final Future> files = CacheStore.instance - .readAll(); + late Future>> _entries = _read(); + + Future>> _read() async => + (await CacheStore.instance.readAll()).entries.toList() + ..sort((a, b) => b.value.lastUpdate.compareTo(a.value.lastUpdate)); + + void _reload() => setState(() { + _entries = _read(); + }); + + void _confirmDelete(String key) { + ConfirmDialog( + title: 'Eintrag löschen', + content: '„$key“ wird gelöscht und bei Bedarf neu geladen.', + confirmButton: 'Löschen', + errorBuilder: (e) => '$e', + onConfirmAsync: () async { + await CacheStore.instance.delete(key); + if (mounted) _reload(); + }, + ).asDialog(context); + } + + void _confirmClear() { + ConfirmDialog( + title: 'Request-Cache leeren', + content: + 'Alle Einträge werden gelöscht. Der Cache baut sich bei Nutzung der ' + 'App automatisch wieder auf.', + confirmButton: 'Alles löschen', + errorBuilder: (e) => '$e', + onConfirmAsync: () async { + await CacheStore.instance.clear(); + if (mounted) _reload(); + }, + ).asDialog(context); + } @override Widget build(BuildContext context) => Scaffold( - appBar: AppBar(title: const Text('Cache storage')), + appBar: AppBar( + title: const Text('Request-Cache'), + actions: [ + IconButton( + tooltip: 'Neu laden', + icon: const Icon(Icons.refresh), + onPressed: _reload, + ), + IconButton( + tooltip: 'Alles löschen', + icon: const Icon(Icons.delete_sweep_outlined), + onPressed: _confirmClear, + ), + ], + ), body: FutureBuilder( - future: files, + future: _entries, builder: (context, snapshot) { - if (snapshot.hasData && snapshot.data!.isNotEmpty) { - return ListView.builder( - itemCount: snapshot.data!.length, - itemBuilder: (context, index) { - final key = snapshot.data!.keys.elementAt(index); - final element = snapshot.data![key]!; - - return ListTile( - leading: const Icon(Icons.text_snippet_outlined), - title: Text(key), - subtitle: Text( - '${filesize(utf8.encode(element.json).length)}, ${Jiffy.parseFromMillisecondsSinceEpoch(element.lastUpdate).fromNow()}', - ), - trailing: const Icon(Icons.arrow_right), - onTap: () => JsonViewer.asDialog( - context, - jsonDecode(element.json) as Map, - ), - ); - }, - ); - } else if (snapshot.connectionState != ConnectionState.done) { + if (snapshot.connectionState != ConnectionState.done) { return const Center(child: AppProgressIndicator.large()); - } else { - return const Center( - child: PlaceholderView( - icon: Icons.hourglass_empty, - text: 'Keine Daten', - ), + } + final entries = snapshot.data ?? const []; + if (entries.isEmpty) { + return const PlaceholderView( + icon: Icons.hourglass_empty, + text: 'Keine Daten', ); } + return ListView.builder( + itemCount: entries.length, + itemBuilder: (context, index) { + final MapEntry(:key, :value) = entries[index]; + return ListTile( + leading: const Icon(Icons.text_snippet_outlined), + title: Text(key), + subtitle: Text( + '${filesize(utf8.encode(value.json).length)} · ' + '${DateTime.fromMillisecondsSinceEpoch(value.lastUpdate).formatRelative()}', + ), + onTap: () => AppRoutes.openJsonViewer( + context, + jsonDecode(value.json), + title: key, + ), + trailing: IconButton( + tooltip: 'Löschen', + icon: const Icon(Icons.delete_outline), + onPressed: () => _confirmDelete(key), + ), + ); + }, + ); }, ), ); diff --git a/lib/widget/debug/debug_stats_overlay.dart b/lib/widget/debug/debug_stats_overlay.dart new file mode 100644 index 0000000..b23e2fb --- /dev/null +++ b/lib/widget/debug/debug_stats_overlay.dart @@ -0,0 +1,205 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math'; +import 'dart:ui'; + +import 'package:filesize/filesize.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; + +/// Release-capable frame and memory statistics drawn above [child]. Frame data +/// comes from [SchedulerBinding.addTimingsCallback], which the engine reports +/// in batches (about once per second in release builds). +class DebugStatsOverlay extends StatefulWidget { + final bool showFrameStats; + final bool showMemoryStats; + final Widget child; + + const DebugStatsOverlay({ + required this.showFrameStats, + required this.showMemoryStats, + required this.child, + super.key, + }); + + @override + State createState() => _DebugStatsOverlayState(); +} + +class _DebugStatsOverlayState extends State { + static const _sampleWindow = 240; + + final List _frames = []; + int _jankTotal = 0; + int _frameTotal = 0; + Timer? _ticker; + bool _listening = false; + + bool get _active => widget.showFrameStats || widget.showMemoryStats; + + @override + void initState() { + super.initState(); + _sync(); + } + + @override + void didUpdateWidget(DebugStatsOverlay oldWidget) { + super.didUpdateWidget(oldWidget); + _sync(); + } + + void _sync() { + if (widget.showFrameStats && !_listening) { + SchedulerBinding.instance.addTimingsCallback(_onTimings); + _listening = true; + } else if (!widget.showFrameStats && _listening) { + SchedulerBinding.instance.removeTimingsCallback(_onTimings); + _listening = false; + _frames.clear(); + _jankTotal = 0; + _frameTotal = 0; + } + if (_active) { + _ticker ??= Timer.periodic( + const Duration(seconds: 1), + (_) => setState(() {}), + ); + } else { + _ticker?.cancel(); + _ticker = null; + } + } + + Duration get _budget { + final rate = View.maybeOf(context)?.display.refreshRate ?? 60; + return Duration(microseconds: (1e6 / (rate > 0 ? rate : 60)).round()); + } + + void _onTimings(List timings) { + final budget = _budget; + for (final t in timings) { + _frameTotal++; + if (t.buildDuration > budget || t.rasterDuration > budget) _jankTotal++; + } + _frames.addAll(timings); + if (_frames.length > _sampleWindow) { + _frames.removeRange(0, _frames.length - _sampleWindow); + } + } + + @override + void dispose() { + if (_listening) SchedulerBinding.instance.removeTimingsCallback(_onTimings); + _ticker?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (!_active) return widget.child; + return Stack( + textDirection: TextDirection.ltr, + children: [ + widget.child, + Positioned( + left: 4, + bottom: MediaQuery.paddingOf(context).bottom + 4, + child: IgnorePointer( + child: MediaQuery.withNoTextScaling( + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(4), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 4, + ), + child: DefaultTextStyle( + style: const TextStyle( + color: Colors.white, + fontSize: 10, + height: 1.3, + fontFamily: 'monospace', + decoration: TextDecoration.none, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.showFrameStats) ..._frameLines(), + if (widget.showMemoryStats) ..._memoryLines(), + ], + ), + ), + ), + ), + ), + ), + ), + ], + ); + } + + List _frameLines() { + final budgetMs = _budget.inMicroseconds / 1000; + if (_frames.isEmpty) { + return [Text('Frames: – (Budget ${budgetMs.toStringAsFixed(1)} ms)')]; + } + final build = _sortedMs((t) => t.buildDuration); + final raster = _sortedMs((t) => t.rasterDuration); + final jankPct = _frameTotal == 0 ? 0 : _jankTotal * 100 / _frameTotal; + final lastWallclock = _frames.last.timestampInMicroseconds( + FramePhase.rasterFinish, + ); + final recent = _frames + .where( + (t) => + lastWallclock - + t.timestampInMicroseconds(FramePhase.rasterFinish) < + 1000000, + ) + .length; + return [ + Text( + 'Budget ${budgetMs.toStringAsFixed(1)} ms · letzte Sek. $recent Frames', + ), + Text( + 'Build p50 ${_p(build, 50)} p90 ${_p(build, 90)} max ${_ms(build.last)}', + ), + Text( + 'Raster p50 ${_p(raster, 50)} p90 ${_p(raster, 90)} max ${_ms(raster.last)}', + ), + Text('Jank $_jankTotal/$_frameTotal (${jankPct.toStringAsFixed(1)} %)'), + ]; + } + + List _memoryLines() { + final cache = PaintingBinding.instance.imageCache; + return [ + Text( + 'RSS ${filesize(ProcessInfo.currentRss)} · max ${filesize(ProcessInfo.maxRss)}', + ), + Text( + 'Bilder ${cache.currentSize}/${cache.maximumSize} · ' + '${filesize(cache.currentSizeBytes)}/${filesize(cache.maximumSizeBytes)} · ' + 'live ${cache.liveImageCount}', + ), + ]; + } + + List _sortedMs(Duration Function(FrameTiming) pick) => + _frames.map((t) => pick(t).inMicroseconds / 1000).toList()..sort(); + + String _p(List sorted, int percentile) { + final i = min( + sorted.length - 1, + (sorted.length * percentile / 100).floor(), + ); + return _ms(sorted[i]); + } + + String _ms(double v) => v.toStringAsFixed(1).padLeft(5); +} diff --git a/lib/widget/debug/debug_tile.dart b/lib/widget/debug/debug_tile.dart index 389ff5f..d47d475 100644 --- a/lib/widget/debug/debug_tile.dart +++ b/lib/widget/debug/debug_tile.dart @@ -2,9 +2,9 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../routing/app_routes.dart'; import '../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../centered_leading.dart'; -import 'json_viewer.dart'; class DebugTile { BuildContext context; @@ -16,9 +16,9 @@ class DebugTile { (onlyInDebug ? kDebugMode : true); Widget jsonData(Map data) => callback( - title: 'JSON daten anzeigen', - onTab: () => JsonViewer.asDialog(context, data), - ); + title: 'JSON daten anzeigen', + onTab: () => AppRoutes.openJsonViewer(context, data), + ); Widget callback({ String title = 'Debugaktion', diff --git a/lib/widget/debug/json_viewer.dart b/lib/widget/debug/json_viewer.dart index 01f7cd4..b7f49db 100644 --- a/lib/widget/debug/json_viewer.dart +++ b/lib/widget/debug/json_viewer.dart @@ -4,43 +4,201 @@ import 'package:flutter/material.dart'; import '../../utils/clipboard_helper.dart'; -class JsonViewer { +/// Collapsible tree view for decoded JSON (maps, lists, scalars). Rows are +/// flattened lazily so multi-megabyte cache payloads stay scrollable. +class JsonViewer extends StatefulWidget { + final String title; + final Object? data; + + const JsonViewer({required this.data, this.title = 'Rohdaten', super.key}); + static final _encoder = const JsonEncoder.withIndent(' '); - static String format(Map jsonInput) => - _encoder.convert(jsonInput); + static String format(Object? data) => _encoder.convert(toEncodable(data)); - static void asDialog(BuildContext context, Map dataMap) { - showDialog( - context: context, - builder: (dialogCtx) => AlertDialog( - scrollable: true, - title: const Row( - children: [Icon(Icons.bug_report_outlined), Text('Rohdaten')], + /// Makes arbitrary decoded storage values (Hive maps with dynamic keys, + /// DateTimes, …) JSON-encodable. + static Object? toEncodable(Object? value) => switch (value) { + null || bool() || num() || String() => value, + Map() => {for (final e in value.entries) '${e.key}': toEncodable(e.value)}, + Iterable() => [for (final e in value) toEncodable(e)], + DateTime() => value.toIso8601String(), + _ => value.toString(), + }; + + @override + State createState() => _JsonViewerState(); +} + +class _Row { + final String path; + final String? label; + final Object? value; + final int depth; + + const _Row(this.path, this.label, this.value, this.depth); + + bool get isContainer => value is Map || value is List; +} + +class _JsonViewerState extends State { + late final Object? _data = JsonViewer.toEncodable(widget.data); + final Set _expanded = {''}; + List<_Row> _rows = const []; + + @override + void initState() { + super.initState(); + _rebuildRows(); + } + + void _rebuildRows() { + final rows = <_Row>[]; + void walk(String path, String? label, Object? value, int depth) { + rows.add(_Row(path, label, value, depth)); + if (!_expanded.contains(path)) return; + if (value is Map) { + for (final e in value.entries) { + walk('$path/${e.key}', '${e.key}', e.value, depth + 1); + } + } else if (value is List) { + for (var i = 0; i < value.length; i++) { + walk('$path/$i', '[$i]', value[i], depth + 1); + } + } + } + + walk('', null, _data, 0); + _rows = rows; + } + + void _toggle(String path) => setState(() { + if (!_expanded.remove(path)) _expanded.add(path); + _rebuildRows(); + }); + + void _setAll({required bool expanded}) => setState(() { + _expanded + ..clear() + ..add(''); + if (expanded) { + void collect(String path, Object? value) { + if (value is Map) { + _expanded.add(path); + for (final e in value.entries) { + collect('$path/${e.key}', e.value); + } + } else if (value is List) { + _expanded.add(path); + for (var i = 0; i < value.length; i++) { + collect('$path/$i', value[i]); + } + } + } + + collect('', _data); + } + _rebuildRows(); + }); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: Text(widget.title), + actions: [ + IconButton( + tooltip: 'Alles aufklappen', + icon: const Icon(Icons.unfold_more), + onPressed: () => _setAll(expanded: true), ), - content: Text(JsonViewer.format(dataMap)), - actions: [ - TextButton( - onPressed: () => copyToClipboard( - dialogCtx, - JsonViewer.format(dataMap), - successMessage: 'Formatiertes JSON kopiert', + IconButton( + tooltip: 'Alles zuklappen', + icon: const Icon(Icons.unfold_less), + onPressed: () => _setAll(expanded: false), + ), + PopupMenuButton( + onSelected: (pretty) => copyToClipboard( + context, + pretty ? JsonViewer.format(_data) : jsonEncode(_data), + successMessage: pretty + ? 'Formatiertes JSON kopiert' + : 'Inline JSON kopiert', + ), + itemBuilder: (_) => const [ + PopupMenuItem(value: true, child: Text('JSON kopieren')), + PopupMenuItem(value: false, child: Text('Inline kopieren')), + ], + ), + ], + ), + body: ListView.builder( + itemCount: _rows.length, + itemBuilder: (context, index) => _buildRow(context, _rows[index]), + ), + ); + + Widget _buildRow(BuildContext context, _Row row) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final mono = theme.textTheme.bodyMedium!.copyWith(fontFamily: 'monospace'); + final value = row.value; + final expanded = _expanded.contains(row.path); + + final (String text, Color color) = switch (value) { + Map() => (expanded ? '{' : '{ ${value.length} }', colors.outline), + List() => (expanded ? '[' : '[ ${value.length} ]', colors.outline), + String() => ('"$value"', colors.tertiary), + num() || bool() => ('$value', colors.primary), + _ => ('null', colors.outline), + }; + + return InkWell( + onTap: row.isContainer ? () => _toggle(row.path) : null, + onLongPress: () => copyToClipboard( + context, + value is String ? value : JsonViewer.format(value), + ), + child: Padding( + padding: EdgeInsets.only( + left: 8.0 + row.depth * 16, + right: 8, + top: 3, + bottom: 3, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 20, + child: row.isContainer + ? Icon( + expanded ? Icons.arrow_drop_down : Icons.arrow_right, + size: 20, + ) + : null, ), - child: const Text('Kopieren'), - ), - TextButton( - onPressed: () => copyToClipboard( - dialogCtx, - dataMap.toString(), - successMessage: 'Inline JSON kopiert', + Expanded( + child: Text.rich( + TextSpan( + style: mono, + children: [ + if (row.label != null) + TextSpan( + text: '${row.label}: ', + style: TextStyle(color: colors.secondary), + ), + TextSpan( + text: text, + style: TextStyle(color: color), + ), + ], + ), + maxLines: row.isContainer ? 1 : 8, + overflow: TextOverflow.ellipsis, + ), ), - child: const Text('Inline Kopieren'), - ), - TextButton( - onPressed: () => Navigator.of(dialogCtx).pop(), - child: const Text('Schließen'), - ), - ], + ], + ), ), ); } diff --git a/pubspec.yaml b/pubspec.yaml index 4fed116..0bb45e0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,6 +38,9 @@ dependencies: emoji_picker_flutter: ^4.3.0 file_picker: ^11.0.2 filesize: ^2.0.1 + # Disk cache behind cached_network_image; used directly by the dev tools to + # size and empty it. Already present transitively. + flutter_cache_manager: ^3.4.1 firebase_core: ^4.1.0 firebase_messaging: ^16.0.1 flutter_app_badge: ^2.0.2 diff --git a/test/utils/directory_size_test.dart b/test/utils/directory_size_test.dart new file mode 100644 index 0000000..2eddb29 --- /dev/null +++ b/test/utils/directory_size_test.dart @@ -0,0 +1,22 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/utils/directory_size.dart'; + +void main() { + late Directory tmp; + + setUp(() => tmp = Directory.systemTemp.createTempSync('dir_size_test')); + tearDown(() => tmp.deleteSync(recursive: true)); + + test('sums files recursively', () async { + File('${tmp.path}/a').writeAsBytesSync(List.filled(10, 0)); + Directory('${tmp.path}/sub').createSync(); + File('${tmp.path}/sub/b').writeAsBytesSync(List.filled(5, 0)); + expect(await directorySize(tmp), 15); + }); + + test('returns 0 for a missing directory', () async { + expect(await directorySize(Directory('${tmp.path}/missing')), 0); + }); +} From 2aed3d104cd5520eb6892d4b8899105481a58eaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Sat, 26 Sep 2026 23:38:24 +0200 Subject: [PATCH 11/18] push registration no longer depends on notification permission, reporting effective opt-in and os permission --- .../telemetry_heartbeat.dart | 79 ++++++++++++++----- lib/app.dart | 9 --- lib/push/push_registration.dart | 56 ++++++------- lib/push/push_status.dart | 21 ++--- .../pages/settings/sections/talk_section.dart | 12 +-- test/push/push_permission_test.dart | 33 ++++++++ 6 files changed, 129 insertions(+), 81 deletions(-) diff --git a/lib/api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart b/lib/api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart index 8a8b2a5..40a7a60 100644 --- a/lib/api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart +++ b/lib/api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart @@ -3,41 +3,71 @@ import 'dart:convert'; import 'dart:io'; import 'package:device_info_plus/device_info_plus.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:package_info_plus/package_info_plus.dart'; +import '../../../../push/push_registration.dart'; import '../../../../push/push_registration_store.dart'; import '../../../../push/push_registration_type.dart'; import '../../marianumconnect_query.dart'; import 'telemetry_device_id.dart'; +typedef _PushState = ({ + bool enabled, + AuthorizationStatus? permission, + String? pushDeviceIdentifier, +}); + /// Sends a telemetry heartbeat to MarianumConnect (`POST me/telemetry`) — /// upserts the stable install id, platform, app version and device info. Sent -/// on app start, again once push registration completes that session (so a -/// fresh registration isn't under-reported until the next launch), and on -/// resume after the app spent more than 15 minutes in the background. +/// on app start, on resume and once push registration completes, throttled to +/// one per 15 minutes unless the push state changed in between. /// Bearer-authenticated via the shared dio interceptor. Replaces the legacy /// mhsl.eu `server/userIndex/update` call. class TelemetryHeartbeat extends MarianumConnectQuery { TelemetryHeartbeat({super.dio}); + static const Duration _interval = Duration(minutes: 15); + + // Wall-clock throttle rather than Debouncer.throttle: a Timer does not tick + // reliably while the app is suspended, so the window would still be open on + // the resume it is supposed to let through. + static DateTime? _lastSentAt; + static _PushState? _lastPushState; + /// Fire-and-forget: schedules a heartbeat and swallows any error, so a failed - /// send never disrupts app start. Used from the app shell's initState and - /// lifecycle handler, and re-emitted once push registration completes (see - /// `_MainState._syncPush`). + /// send never disrupts app start. A changed push state (toggle, OS + /// permission — e.g. back from the system settings — or a fresh + /// registration) bypasses the throttle: the server picks visible vs. silent + /// delivery from it, so it must not wait for the next window. static void report({required bool notificationsEnabled}) { - unawaited( - TelemetryHeartbeat() - .send(notificationsEnabled: notificationsEnabled) - .catchError((Object _) {}), - ); + unawaited(_report(notificationsEnabled).catchError((Object _) {})); } - Future send({required bool notificationsEnabled}) => guard(() async { + static Future _report(bool enabled) async { + final pushState = ( + enabled: enabled, + permission: await PushRegistration.osPermissionStatus(), + pushDeviceIdentifier: await const PushRegistrationStore() + .deviceIdentifier(PushRegistrationType.general), + ); + final now = DateTime.now(); + final last = _lastSentAt; + if (last != null && + now.difference(last) < _interval && + pushState == _lastPushState) { + return; + } + // Claimed before sending so overlapping triggers don't both go out. + _lastSentAt = now; + _lastPushState = pushState; + await TelemetryHeartbeat()._send(pushState); + } + + Future _send(_PushState push) => guard(() async { final info = DeviceInfoPlugin(); final package = await PackageInfo.fromPlatform(); final deviceIdentifier = await TelemetryDeviceId.resolve(); - final pushDeviceIdentifier = await const PushRegistrationStore() - .deviceIdentifier(PushRegistrationType.general); var platform = 'unknown'; String? deviceModel; @@ -62,11 +92,12 @@ class TelemetryHeartbeat extends MarianumConnectQuery { data: { 'deviceIdentifier': deviceIdentifier, // `pushDeviceIdentifier` reflects a *completed* registration and is - // absent until it lands; `pushEnabled` carries the user's intent - // (the notification toggle) so the backend can tell "user wants push" - // apart from "registration not finished yet". - 'pushDeviceIdentifier': ?pushDeviceIdentifier, - 'pushEnabled': notificationsEnabled, + // absent until it lands; `pushEnabled` carries the user's intent (the + // toggle). The server combines it with `osPermission` into the + // visible-vs-silent delivery decision. + 'pushDeviceIdentifier': ?push.pushDeviceIdentifier, + 'pushEnabled': push.enabled, + 'osPermission': ?osPermissionWireValue(push.permission), 'platform': platform, 'appVersion': package.version, 'appBuild': int.tryParse(package.buildNumber), @@ -77,3 +108,13 @@ class TelemetryHeartbeat extends MarianumConnectQuery { ); }); } + +/// Wire value of the OS notification permission for the heartbeat. +String? osPermissionWireValue(AuthorizationStatus? status) => switch (status) { + AuthorizationStatus.authorized => 'granted', + AuthorizationStatus.provisional => 'provisional', + AuthorizationStatus.denied || + AuthorizationStatus.deniedPermanently => 'denied', + AuthorizationStatus.notDetermined => 'notDetermined', + null => null, +}; diff --git a/lib/app.dart b/lib/app.dart index e6bb8a4..a8d38f1 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -48,11 +48,9 @@ class _AppState extends State with WidgetsBindingObserver { int _knownTotalTabs = 1; int _lastTabIndex = 0; bool _userOnLastTab = false; - DateTime? _lastTelemetryAt; static const Duration _chatListActiveInterval = Duration(seconds: 15); static const Duration _chatListIdleInterval = Duration(seconds: 60); - static const Duration _telemetryInterval = Duration(minutes: 15); void _onTabControllerChanged() { final newIndex = Main.bottomNavigator.index; @@ -81,15 +79,8 @@ class _AppState extends State with WidgetsBindingObserver { } } - // Wall-clock throttle rather than Debouncer.throttle: a Timer does not tick - // reliably while the app is suspended, so the window would still be open on - // the resume it is supposed to let through. void _reportTelemetry() { if (!mounted) return; - final now = DateTime.now(); - final last = _lastTelemetryAt; - if (last != null && now.difference(last) < _telemetryInterval) return; - _lastTelemetryAt = now; TelemetryHeartbeat.report( notificationsEnabled: context .read() diff --git a/lib/push/push_registration.dart b/lib/push/push_registration.dart index 52dc540..e9cd974 100644 --- a/lib/push/push_registration.dart +++ b/lib/push/push_registration.dart @@ -355,9 +355,8 @@ class PushRegistration { status != AuthorizationStatus.deniedPermanently; /// Requests the OS notification permission (covers iOS + Android 13) and - /// returns whether registration should proceed. Errors from the plugin are - /// treated as usable — better a possibly-idle registration than silently - /// losing push over a transient failure. + /// returns whether it is usable (not explicitly denied). Errors from the + /// plugin are treated as usable so a transient failure never nags the user. static Future requestOsPermission() async { try { final settings = await FirebaseMessaging.instance.requestPermission(); @@ -368,54 +367,47 @@ class PushRegistration { } } - /// True when the user has explicitly denied the OS notification permission. - /// Read-only (no prompt) — used by the settings UI to surface the state. - static Future isOsPermissionDenied() async { + /// Current OS notification permission, or null when the plugin can't tell. + /// Read-only — never triggers the OS prompt. + static Future osPermissionStatus() async { try { final settings = await FirebaseMessaging.instance .getNotificationSettings(); - return !isPermissionUsable(settings.authorizationStatus); + return settings.authorizationStatus; } on Object { - return false; + return null; } } /// True when the OS notification permission is already granted /// (`authorized`/`provisional`). Read-only — never triggers the OS prompt. - /// Used by the cold-start/self-heal path so it registers only for devices - /// that already opted in, leaving the actual prompt to the first Talk visit. static Future isOsPermissionGranted() async { - try { - final settings = await FirebaseMessaging.instance - .getNotificationSettings(); - return settings.authorizationStatus == AuthorizationStatus.authorized || - settings.authorizationStatus == AuthorizationStatus.provisional; - } on Object { - return false; - } + final status = await osPermissionStatus(); + return status != null && canDisplay(status); } + /// Whether the OS will actually show a visible notification in [status]. + /// Stricter than [isPermissionUsable]: `notDetermined` shows nothing yet. + static bool canDisplay(AuthorizationStatus status) => + status == AuthorizationStatus.authorized || + status == AuthorizationStatus.provisional; + /// Registers this device whenever the backend advertises the push capability. - /// Deliberately independent of the in-app notification toggle: a user who - /// turned notifications off stays registered so silent sync pushes keep - /// flowing — the display is suppressed downstream via the mirrored flag (see - /// [PushRegistrationStore.notificationsEnabled]). Only registers when the OS - /// notification permission is *already* granted — it never triggers the OS - /// prompt itself. Requesting the permission is the job of the first Talk visit - /// (see `maybePromptTalkNotifications`), which keeps the prompt out of the - /// cold-start path. Safe to call on every start — Nextcloud dedups an - /// unchanged registration — which also self-heals a device whose registration - /// was lost. + /// Deliberately independent of both the in-app notification toggle and the + /// OS notification permission: silent sync pushes (deletes, chat/badge + /// refresh, widget refresh) need no permission — data-only FCM on Android, + /// `content-available` on iOS — so every device stays in sync. Whether a + /// visible notification is wanted is reported separately via the telemetry + /// heartbeat (toggle + OS permission), from which the server picks silent + /// pushes on iOS. Never triggers the OS prompt. Safe to call on every + /// start — Nextcloud dedups an unchanged registration — which also + /// self-heals a device whose registration was lost. /// Returns whether registration was actually *attempted* (all gates passed). /// Even a partial success persists the `general` device identifier, so the /// caller re-emits telemetry on `true` to reflect the fresh registration in /// the same session instead of lagging until the next launch. static Future syncSubscription({required bool capable}) async { if (!capable) return false; - if (!await isOsPermissionGranted()) { - log('Push: OS notification permission not granted, skipping registration'); - return false; - } final registration = PushRegistration(); // register() below refreshes an unchanged subscription anyway; the check // only surfaces the endpoint switch in the log for diagnosability. diff --git a/lib/push/push_status.dart b/lib/push/push_status.dart index a0664cb..6e5f73e 100644 --- a/lib/push/push_status.dart +++ b/lib/push/push_status.dart @@ -1,4 +1,3 @@ -import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; import '../model/account_data.dart'; @@ -141,21 +140,11 @@ Future collectPushStatus({ } Future _osPermission() async { - try { - final settings = await FirebaseMessaging.instance.getNotificationSettings(); - switch (settings.authorizationStatus) { - case AuthorizationStatus.authorized: - case AuthorizationStatus.provisional: - return PushCheck.ok; - case AuthorizationStatus.denied: - case AuthorizationStatus.deniedPermanently: - return PushCheck.fail; - case AuthorizationStatus.notDetermined: - return PushCheck.unknown; - } - } on Object { - return PushCheck.unknown; - } + final status = await PushRegistration.osPermissionStatus(); + if (status == null) return PushCheck.unknown; + if (PushRegistration.canDisplay(status)) return PushCheck.ok; + if (!PushRegistration.isPermissionUsable(status)) return PushCheck.fail; + return PushCheck.unknown; } /// One line in the status checklist. diff --git a/lib/view/pages/settings/sections/talk_section.dart b/lib/view/pages/settings/sections/talk_section.dart index 0a1a79b..ea9ecec 100644 --- a/lib/view/pages/settings/sections/talk_section.dart +++ b/lib/view/pages/settings/sections/talk_section.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../../api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart'; import '../../../../push/push_registration.dart'; import '../../../../push/push_status.dart'; import '../../../../routing/app_routes.dart'; @@ -123,6 +124,8 @@ class _PushSettingsState extends State<_PushSettings> void _onToggle(bool enabled) { widget.settings.val(write: true).notificationSettings.enabled = enabled; + // A permission granted below arrives via the resume heartbeat. + TelemetryHeartbeat.report(notificationsEnabled: enabled); // Turning off does NOT unregister: the device stays subscribed so silent // sync pushes keep arriving; the message handler and iOS NSE suppress only // the visible notification (via the mirrored flag). Enabling (re-)registers @@ -132,11 +135,10 @@ class _PushSettingsState extends State<_PushSettings> final messenger = ScaffoldMessenger.of(context); unawaited(() async { try { - // Only register when the OS permission isn't explicitly denied — - // otherwise NC + proxy would push into the void. - if (await PushRegistration.requestOsPermission()) { - await PushRegistration().register(); - } else { + final granted = await PushRegistration.requestOsPermission(); + // Registered regardless: silent sync pushes need no permission. + await PushRegistration().register(); + if (!granted) { messenger.showSnackBar( const SnackBar( content: Text( diff --git a/test/push/push_permission_test.dart b/test/push/push_permission_test.dart index 8ec6fc7..d029751 100644 --- a/test/push/push_permission_test.dart +++ b/test/push/push_permission_test.dart @@ -1,5 +1,6 @@ import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart'; import 'package:marianum_mobile/push/push_registration.dart'; void main() { @@ -37,4 +38,36 @@ void main() { expect(AuthorizationStatus.values, hasLength(5)); }); }); + + group('PushRegistration.canDisplay', () { + test('only authorized and provisional show notifications', () { + expect( + AuthorizationStatus.values.where(PushRegistration.canDisplay), + unorderedEquals([ + AuthorizationStatus.authorized, + AuthorizationStatus.provisional, + ]), + ); + }); + }); + + group('osPermissionWireValue', () { + test('maps every status', () { + expect(osPermissionWireValue(AuthorizationStatus.authorized), 'granted'); + expect( + osPermissionWireValue(AuthorizationStatus.provisional), + 'provisional', + ); + expect(osPermissionWireValue(AuthorizationStatus.denied), 'denied'); + expect( + osPermissionWireValue(AuthorizationStatus.deniedPermanently), + 'denied', + ); + expect( + osPermissionWireValue(AuthorizationStatus.notDetermined), + 'notDetermined', + ); + expect(osPermissionWireValue(null), isNull); + }); + }); } From 2ca9feb49a26d3f615513050d38bb4d2ac702b26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Sun, 27 Sep 2026 14:07:52 +0200 Subject: [PATCH 12/18] reworked home screen widgets to mirror the in-app timetable, hidden below android 12 --- android/app/src/main/AndroidManifest.xml | 2 + .../mobile/client/widgets/WidgetData.kt | 33 +- .../mobile/client/widgets/WidgetRenderer.kt | 921 +++++++++++------- .../main/res/drawable/widget_icon_coffee.xml | 11 + .../main/res/drawable/widget_icon_event.xml | 11 + .../res/drawable/widget_icon_restaurant.xml | 11 + .../main/res/drawable/widget_lesson_block.xml | 7 + .../widget_lesson_block_cancelled.xml | 16 - .../widget_lesson_block_event_blue.xml | 6 - .../widget_lesson_block_event_green.xml | 6 - .../widget_lesson_block_event_orange.xml | 6 - .../widget_lesson_block_irregular.xml | 6 - .../drawable/widget_lesson_block_ongoing.xml | 6 - .../res/drawable/widget_lesson_block_past.xml | 6 - .../drawable/widget_lesson_block_regular.xml | 6 - .../widget_lesson_block_teacher_changed.xml | 6 - .../widget_lesson_cancelled_overlay.xml | 16 + .../drawable/widget_lesson_cancelled_x.xml | 4 +- ..._event_red.xml => widget_today_circle.xml} | 5 +- .../main/res/layout/widget_break_block.xml | 14 +- .../main/res/layout/widget_break_label.xml | 16 + .../app/src/main/res/layout/widget_day.xml | 53 +- .../main/res/layout/widget_day_preview.xml | 257 +++-- .../main/res/layout/widget_holiday_column.xml | 63 ++ .../main/res/layout/widget_lesson_block.xml | 145 +-- .../src/main/res/layout/widget_time_label.xml | 53 +- .../app/src/main/res/layout/widget_week.xml | 54 +- .../res/layout/widget_week_day_header.xml | 31 +- .../main/res/layout/widget_week_preview.xml | 353 +++++-- .../main/res/values-night/widget_colors.xml | 2 + .../main/res/values-v31/widget_support.xml | 4 + android/app/src/main/res/values/strings.xml | 1 + .../app/src/main/res/values/widget_colors.xml | 12 +- .../src/main/res/values/widget_support.xml | 7 + .../TimetableDayView.swift | 521 +++++----- .../TimetableLayout.swift | 279 ++++++ .../TimetableWeekView.swift | 173 ++-- ios/TimetableWidgetExtension/WidgetData.swift | 3 + .../WidgetPalette.swift | 20 +- lib/widget_data/widget_data.dart | 6 +- lib/widget_data/widget_data.freezed.dart | 120 ++- lib/widget_data/widget_data.g.dart | 2 + lib/widget_data/widget_data_mapper.dart | 60 +- test/widget_data/widget_data_mapper_test.dart | 312 +++++- 44 files changed, 2401 insertions(+), 1245 deletions(-) create mode 100644 android/app/src/main/res/drawable/widget_icon_coffee.xml create mode 100644 android/app/src/main/res/drawable/widget_icon_event.xml create mode 100644 android/app/src/main/res/drawable/widget_icon_restaurant.xml create mode 100644 android/app/src/main/res/drawable/widget_lesson_block.xml delete mode 100644 android/app/src/main/res/drawable/widget_lesson_block_cancelled.xml delete mode 100644 android/app/src/main/res/drawable/widget_lesson_block_event_blue.xml delete mode 100644 android/app/src/main/res/drawable/widget_lesson_block_event_green.xml delete mode 100644 android/app/src/main/res/drawable/widget_lesson_block_event_orange.xml delete mode 100644 android/app/src/main/res/drawable/widget_lesson_block_irregular.xml delete mode 100644 android/app/src/main/res/drawable/widget_lesson_block_ongoing.xml delete mode 100644 android/app/src/main/res/drawable/widget_lesson_block_past.xml delete mode 100644 android/app/src/main/res/drawable/widget_lesson_block_regular.xml delete mode 100644 android/app/src/main/res/drawable/widget_lesson_block_teacher_changed.xml create mode 100644 android/app/src/main/res/drawable/widget_lesson_cancelled_overlay.xml rename android/app/src/main/res/drawable/{widget_lesson_block_event_red.xml => widget_today_circle.xml} (51%) create mode 100644 android/app/src/main/res/layout/widget_break_label.xml create mode 100644 android/app/src/main/res/layout/widget_holiday_column.xml create mode 100644 android/app/src/main/res/values-v31/widget_support.xml create mode 100644 android/app/src/main/res/values/widget_support.xml create mode 100644 ios/TimetableWidgetExtension/TimetableLayout.swift diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 123c275..7ebf31e 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -81,6 +81,7 @@ the home_widget Flutter plugin resolves them as .. --> @@ -93,6 +94,7 @@ diff --git a/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/widgets/WidgetData.kt b/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/widgets/WidgetData.kt index 5c0ccd2..57b8324 100644 --- a/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/widgets/WidgetData.kt +++ b/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/widgets/WidgetData.kt @@ -11,7 +11,7 @@ import java.util.TimeZone // Mirror of lib/widget_data/widget_data.dart — JSON keys + enum names // must stay in sync. enum class WidgetLessonStatus { - REGULAR, ONGOING, PAST, CANCELLED, IRREGULAR, TEACHER_CHANGED, EVENT; + REGULAR, ONGOING, PAST, CANCELLED, IRREGULAR, TEACHER_CHANGED, EVENT, DUTY; companion object { fun fromWire(raw: String?): WidgetLessonStatus = when (raw) { @@ -22,6 +22,7 @@ enum class WidgetLessonStatus { "irregular" -> IRREGULAR "teacherChanged" -> TEACHER_CHANGED "event" -> EVENT + "duty" -> DUTY else -> REGULAR } } @@ -39,6 +40,9 @@ data class WidgetLesson( val originalTeacher: String?, val status: WidgetLessonStatus, val customColor: String?, + // Tile colour resolved in Dart (subject colours, palettes); null only for + // payloads written before the field existed. + val color: Int?, val siblingCount: Int, ) @@ -50,6 +54,12 @@ data class WidgetPeriod( val virtualEndMinutes: Int, ) +data class WidgetDayInfo( + val date: Date, + val isHoliday: Boolean, + val holidayName: String?, +) + data class WidgetTimetableData( val fetchedAt: Date, val anchorDate: Date, @@ -57,6 +67,8 @@ data class WidgetTimetableData( val periods: List, val isHoliday: Boolean, val holidayName: String?, + // Week payload only; empty for the day payload. + val days: List, ) object WidgetDataParser { @@ -81,6 +93,11 @@ object WidgetDataParser { } } + private fun parseColor(raw: String?): Int? { + if (raw == null || !Regex("#[0-9A-Fa-f]{6}").matches(raw)) return null + return 0xFF000000.toInt() or raw.substring(1).toInt(16) + } + fun parse(json: String?): WidgetTimetableData? { if (json.isNullOrEmpty()) return null return try { @@ -102,6 +119,7 @@ object WidgetDataParser { originalTeacher = obj.stringOrNull("originalTeacher"), status = WidgetLessonStatus.fromWire(obj.stringOrNull("status")), customColor = obj.stringOrNull("customColor"), + color = parseColor(obj.stringOrNull("color")), siblingCount = obj.optInt("siblingCount", 0), ) } @@ -120,6 +138,18 @@ object WidgetDataParser { ) } } + val daysArray = root.optJSONArray("days") + val days = mutableListOf() + if (daysArray != null) { + for (i in 0 until daysArray.length()) { + val obj = daysArray.optJSONObject(i) ?: continue + days += WidgetDayInfo( + date = parseDate(obj.stringOrNull("date")) ?: continue, + isHoliday = obj.optBoolean("isHoliday", false), + holidayName = obj.stringOrNull("holidayName"), + ) + } + } WidgetTimetableData( fetchedAt = parseDate(root.stringOrNull("fetchedAt")) ?: Date(), anchorDate = parseDate(root.stringOrNull("anchorDate")) ?: Date(), @@ -127,6 +157,7 @@ object WidgetDataParser { periods = periods, isHoliday = root.optBoolean("isHoliday", false), holidayName = root.stringOrNull("holidayName"), + days = days, ) } catch (_: JSONException) { null diff --git a/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/widgets/WidgetRenderer.kt b/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/widgets/WidgetRenderer.kt index 08c5875..23a9c3a 100644 --- a/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/widgets/WidgetRenderer.kt +++ b/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/widgets/WidgetRenderer.kt @@ -5,8 +5,14 @@ import android.appwidget.AppWidgetManager import android.content.Context import android.content.Intent import android.content.SharedPreferences +import android.content.res.ColorStateList import android.content.res.Configuration +import android.graphics.Typeface import android.os.Bundle +import android.text.SpannableString +import android.text.Spanned +import android.text.TextPaint +import android.text.style.StyleSpan import android.util.TypedValue import android.view.View import android.widget.RemoteViews @@ -16,40 +22,89 @@ import java.text.SimpleDateFormat import java.util.Calendar import java.util.Date import java.util.Locale +import kotlin.math.floor import kotlin.math.max +import kotlin.math.min /** - * Renders the day and week widgets as a time-grid: lesson blocks absolutely - * positioned on a vertical axis, mirroring the in-app Syncfusion calendar. - * Per-hour dp is computed from the widget bundle so the grid scales with - * resize, clamped to [MIN_HOUR_HEIGHT_DP, MAX_HOUR_HEIGHT_DP]. + * Renders the day and week widgets as a period grid that mirrors the in-app + * timetable (AppointmentTile, _PeriodLabel, _DayHeaderCell): lesson tiles + * absolutely positioned on a vertical axis, scaled down from the app's + * proportions to whatever height the launcher grants. */ object WidgetRenderer { private const val FALLBACK_VIRTUAL_MINUTES = 11 * 60 + private const val FALLBACK_SLOT_MINUTES = 45 + private const val FALLBACK_HOUR_HEIGHT_DP = 32f - private const val DAY_CHROME_DP = 40 - private const val WEEK_CHROME_DP = 64 + // Chrome metrics — must match widget_day.xml / widget_week.xml, the grid + // height is derived from them. + private const val CHROME_PADDING_H_DP = 16f + private const val CHROME_PADDING_V_DP = 18f + private const val TITLE_ROW_DP = 20f + private const val SUBTITLE_BELOW_DP = 12f + private const val GRID_MARGIN_TOP_DP = 4f + private const val WEEK_HEADER_DP = 32f + 2f + private const val RULER_WIDTH_DP = 30f + 1f + private const val WEEK_DIVIDERS_DP = 4f - private const val MIN_HOUR_HEIGHT_DP = 18 - private const val MAX_HOUR_HEIGHT_DP = 72 + /// Below this width title and freshness label no longer fit side by side. + private const val DAY_NARROW_WIDTH_DP = 200f - private const val MIN_BLOCK_HEIGHT_DP = 16 + /// Trailing periods without lessons are cut off so the tiles get more + /// height — but never below this many periods, or a short day would + /// blow up to a handful of oversized tiles. + private const val MIN_VISIBLE_PERIODS = 6 + + private const val MIN_HOUR_HEIGHT_DP = 18f + private const val MAX_HOUR_HEIGHT_DP = 72f + + private const val MIN_BLOCK_HEIGHT_DP = 16f private const val LESSON_GAP_DP = 1.5f - // Below SHOW_ROOM_MIN: subject only. Below SHOW_TEACHER_SEPARATE_MIN: - // subject + room. Above: subject + room + teacher stacked. - private const val BLOCK_SHOW_ROOM_MIN_DP = 18 - private const val BLOCK_SHOW_TEACHER_SEPARATE_MIN_DP = 30 + // Tile metrics — must match widget_lesson_block.xml. + private const val TILE_MARGIN_H_DP = 1f + private const val TILE_PADDING_H_DP = 4f + private const val TILE_PADDING_V_DP = 2f - /// Below this column width autoSize can't fit subject + room — drop - /// room/teacher entirely on the week-widget. - private const val WEEK_COLUMN_TIGHT_DP = 45 + // In-app tile: 15sp title over 10sp body lines. The widget keeps that + // ratio and scales both so title + room + teacher fill one period. + private const val TITLE_TO_BODY_RATIO = 1.5f + private const val MAX_BODY_FONT_DP = 10f + private const val MIN_BODY_FONT_DP = 6.5f + private const val LINE_BOX_PER_FONT = 1.2f + + /// If stacking room and teacher would push the body font below this, + /// both share one line instead so the text stays readable. + private const val STACKED_BODY_MIN_FONT_DP = 7.5f + + /// A title that needs to shrink below this to fit on one line wraps onto + /// a second line instead, if the tile has the height for it. + private const val TITLE_WRAP_BELOW_FONT_DP = 8f + + /// Slack for the launcher-reported width when sizing text ahead of layout. + private const val WIDTH_ESTIMATE_SAFETY = 0.95f + + // Footprint of the "+N" badge in the tile's bottom end corner. + private const val BADGE_WIDTH_DP = 16f + private const val BADGE_HEIGHT_DP = 11f + + private const val RULER_TIMES_MIN_CELL_DP = 30f + + /// Space the holiday column keeps free above and below the rotated name + /// for the icon and the "Schulfrei" label. + private const val HOLIDAY_NAME_INSET_DP = 44f + private const val BREAK_ICON_MIN_HEIGHT_DP = 12f private val timeFormat = SimpleDateFormat("HH:mm", Locale.GERMAN) private val dateShort = SimpleDateFormat("dd.MM.", Locale.GERMAN) private val weekdayShort = SimpleDateFormat("EE", Locale.GERMAN) private val dateTimeShort = SimpleDateFormat("dd.MM. HH:mm", Locale.GERMAN) + private val titleTypeface: Typeface by lazy { + Typeface.create("sans-serif-medium", Typeface.NORMAL) + } + /// Hex values mirror LightAppTheme / DarkAppTheme tokens so the widget /// matches the app's branding rather than the generic system look. private data class WidgetPalette( @@ -57,7 +112,11 @@ object WidgetRenderer { val textPrimary: Int, val textSecondary: Int, val divider: Int, + val accent: Int, + val onAccent: Int, val breakBlock: Int, + val todayTint: Int, + val holidayTint: Int, val watermarkAlpha: Float, ) @@ -66,7 +125,11 @@ object WidgetRenderer { textPrimary = 0xFF1A1A1A.toInt(), textSecondary = 0xFF555555.toInt(), divider = 0x22000000, - breakBlock = 0x0C000000, + accent = 0xFF904A47.toInt(), + onAccent = 0xFFFFFFFF.toInt(), + breakBlock = 0x32904A47, + todayTint = 0x0E904A47, + holidayTint = 0x32000000, watermarkAlpha = 0.014f, ) @@ -75,10 +138,54 @@ object WidgetRenderer { textPrimary = 0xFFF1F1F1.toInt(), textSecondary = 0xFFB0B0B0.toInt(), divider = 0x33FFFFFF, - breakBlock = 0x14FFFFFF, + accent = 0xFFFFB3AF.toInt(), + onAccent = 0xFF571D1D.toInt(), + breakBlock = 0x32FFB3AF, + todayTint = 0x0EFFB3AF, + holidayTint = 0x32FFFFFF, watermarkAlpha = 0.025f, ) + private data class SizeDp(val width: Float, val height: Float) + + /// Font and line-box sizes shared by every tile of one widget, derived + /// from the height of a single period. + private class TileMetrics(slotHeightDp: Float) { + val maxBodyLines: Int + val titleBoxDp: Float + val bodyBoxDp: Float + val bodyFontDp: Float + + init { + val inner = slotHeightDp - LESSON_GAP_DP - 2 * TILE_PADDING_V_DP + fun bodyFont(lines: Int) = + inner / (LINE_BOX_PER_FONT * (TITLE_TO_BODY_RATIO + lines)) + maxBodyLines = if (bodyFont(2) < STACKED_BODY_MIN_FONT_DP) 1 else 2 + bodyFontDp = bodyFont(maxBodyLines) + .coerceIn(MIN_BODY_FONT_DP, MAX_BODY_FONT_DP) + titleBoxDp = bodyFontDp * TITLE_TO_BODY_RATIO * LINE_BOX_PER_FONT + bodyBoxDp = bodyFontDp * LINE_BOX_PER_FONT + } + } + + private class GridContext( + val context: Context, + val packageName: String, + val palette: WidgetPalette, + val periods: List, + val hourHeightDp: Float, + /// Null when the launcher reported no size — text wrapping decisions + /// that depend on the tile width are skipped then. + val tileInnerWidthDp: Float?, + ) { + val metrics = TileMetrics( + (periods.firstOrNull()?.let { it.virtualEndMinutes - it.virtualStartMinutes } + ?: FALLBACK_SLOT_MINUTES) * hourHeightDp / 60f, + ) + + fun dp(virtualMinutes: Float): Float = virtualMinutes * hourHeightDp / 60f + } + private fun resolvePalette(context: Context, themeMode: String?): WidgetPalette { val isDark = when (themeMode) { "light" -> false @@ -114,63 +221,9 @@ object WidgetRenderer { context.getString(R.string.widget_loading), palette, ) - - val totalVirtualMin = data.periods.lastOrNull()?.virtualEndMinutes - ?: FALLBACK_VIRTUAL_MINUTES - val hourHeightDp = resolveHourHeight(options, DAY_CHROME_DP, totalVirtualMin) - - val views = RemoteViews(packageName, R.layout.widget_day) - applyChrome(views, palette, options) - views.setTextColor(R.id.widget_day_title, palette.textPrimary) - views.setTextColor(R.id.widget_day_subtitle, palette.textSecondary) - views.setTextColor(R.id.widget_day_empty, palette.textSecondary) - views.setTextViewText( - R.id.widget_day_title, - "${dayLabel(context, data.anchorDate)} · ${dateShort.format(data.anchorDate)}", - ) - views.setTextViewText( - R.id.widget_day_subtitle, - context.getString(R.string.widget_status_label, freshnessLabel(context, data.fetchedAt)), - ) - - views.removeAllViews(R.id.widget_day_time_labels) - views.removeAllViews(R.id.widget_day_grid) - - if (data.isHoliday) { - views.setViewVisibility(R.id.widget_day_empty, View.VISIBLE) - views.setTextViewText( - R.id.widget_day_empty, - data.holidayName ?: context.getString(R.string.widget_holiday), - ) - } else if (data.lessons.isEmpty()) { - views.setViewVisibility(R.id.widget_day_empty, View.VISIBLE) - views.setTextViewText( - R.id.widget_day_empty, - context.getString(R.string.widget_no_lessons), - ) - } else { - views.setViewVisibility(R.id.widget_day_empty, View.GONE) - populateGridLines(packageName, views, R.id.widget_day_time_labels, hourHeightDp, palette, data.periods) - populateTimeLabels(packageName, views, R.id.widget_day_time_labels, hourHeightDp, palette, data.periods) - populateGridLines(packageName, views, R.id.widget_day_grid, hourHeightDp, palette, data.periods) - populateBreakBlocks(packageName, views, R.id.widget_day_grid, hourHeightDp, palette, data.periods) - for (lesson in data.lessons) { - addLessonBlock( - context = context, - packageName = packageName, - parent = views, - containerId = R.id.widget_day_grid, - lesson = lesson, - hourHeightDp = hourHeightDp, - periods = data.periods, - subjectOnly = false, - horizontalPaddingDp = 7, - ) - } + return forBothOrientations(options) { size -> + renderDay(context, packageName, palette, data, size) } - - views.setOnClickPendingIntent(R.id.widget_root, openAppIntent(context)) - return views } fun buildWeek( @@ -195,13 +248,152 @@ object WidgetRenderer { context.getString(R.string.widget_loading), palette, ) + return forBothOrientations(options) { size -> + renderWeek(context, packageName, palette, data, size) + } + } - val totalVirtualMin = data.periods.lastOrNull()?.virtualEndMinutes - ?: FALLBACK_VIRTUAL_MINUTES - val hourHeightDp = resolveHourHeight(options, WEEK_CHROME_DP, totalVirtualMin) + /// The launcher swaps between the two layouts on rotation without asking + /// the provider again, so both have to be laid out for their own size. + private fun forBothOrientations( + options: Bundle?, + render: (SizeDp?) -> RemoteViews, + ): RemoteViews { + val sizes = widgetSizes(options) ?: return render(null) + val (portrait, landscape) = sizes + if (portrait == landscape) return render(portrait) + return RemoteViews(render(landscape), render(portrait)) + } + + /// Launchers report the widget as a min/max box: portrait is the narrow + /// and tall combination, landscape the wide and short one. + private fun widgetSizes(options: Bundle?): Pair? { + if (options == null) return null + val minWidth = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH, 0) + val maxWidth = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH, 0) + val minHeight = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, 0) + val maxHeight = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, 0) + val portrait = SizeDp( + (if (minWidth > 0) minWidth else maxWidth).toFloat(), + (if (maxHeight > 0) maxHeight else minHeight).toFloat(), + ) + val landscape = SizeDp( + (if (maxWidth > 0) maxWidth else minWidth).toFloat(), + (if (minHeight > 0) minHeight else maxHeight).toFloat(), + ) + if (portrait.width <= 0 || portrait.height <= 0) return null + return Pair(portrait, landscape) + } + + private fun renderDay( + context: Context, + packageName: String, + palette: WidgetPalette, + data: WidgetTimetableData, + size: SizeDp?, + ): RemoteViews { + val narrow = size != null && size.width < DAY_NARROW_WIDTH_DP + val chromeDp = CHROME_PADDING_V_DP + TITLE_ROW_DP + GRID_MARGIN_TOP_DP + + (if (narrow) SUBTITLE_BELOW_DP else 0f) + val periods = trimTrailingPeriods(data.periods, data.lessons) + val grid = GridContext( + context = context, + packageName = packageName, + palette = palette, + periods = periods, + hourHeightDp = resolveHourHeight(size, chromeDp, periods), + tileInnerWidthDp = size?.let { + it.width - CHROME_PADDING_H_DP - RULER_WIDTH_DP - tileInsetsDp() + }, + ) + + val views = RemoteViews(packageName, R.layout.widget_day) + applyChrome(views, palette, size) + views.setTextColor(R.id.widget_day_title, palette.textPrimary) + views.setTextColor(R.id.widget_day_subtitle, palette.textSecondary) + views.setTextColor(R.id.widget_day_subtitle_below, palette.textSecondary) + views.setTextColor(R.id.widget_day_empty, palette.textSecondary) + views.setTextViewText( + R.id.widget_day_title, + "${dayLabel(context, data.anchorDate)} · ${dateShort.format(data.anchorDate)}", + ) + val freshness = context.getString( + R.string.widget_status_label, + freshnessLabel(context, data.fetchedAt), + ) + views.setTextViewText(R.id.widget_day_subtitle, freshness) + views.setTextViewText(R.id.widget_day_subtitle_below, freshness) + views.setViewVisibility( + R.id.widget_day_subtitle, + if (narrow) View.GONE else View.VISIBLE, + ) + views.setViewVisibility( + R.id.widget_day_subtitle_below, + if (narrow) View.VISIBLE else View.GONE, + ) + + views.removeAllViews(R.id.widget_day_time_labels) + views.removeAllViews(R.id.widget_day_grid) + + if (data.isHoliday) { + views.setViewVisibility(R.id.widget_day_empty, View.VISIBLE) + views.setTextViewText( + R.id.widget_day_empty, + data.holidayName ?: context.getString(R.string.widget_holiday), + ) + } else if (data.lessons.isEmpty()) { + views.setViewVisibility(R.id.widget_day_empty, View.VISIBLE) + views.setTextViewText( + R.id.widget_day_empty, + context.getString(R.string.widget_no_lessons), + ) + } else { + views.setViewVisibility(R.id.widget_day_empty, View.GONE) + populateRuler(grid, views, R.id.widget_day_time_labels) + populateGridLines(grid, views, R.id.widget_day_grid) + populateBreakBlocks(grid, views, R.id.widget_day_grid) + for (lesson in data.lessons) { + addLessonBlock(grid, views, R.id.widget_day_grid, lesson) + } + } + + views.setOnClickPendingIntent(R.id.widget_root, openAppIntent(context)) + return views + } + + private fun renderWeek( + context: Context, + packageName: String, + palette: WidgetPalette, + data: WidgetTimetableData, + size: SizeDp?, + ): RemoteViews { + val chromeDp = CHROME_PADDING_V_DP + TITLE_ROW_DP + GRID_MARGIN_TOP_DP + + WEEK_HEADER_DP + val weekDays = (0 until 5).map { offset -> + Calendar.getInstance().apply { + time = data.anchorDate + add(Calendar.DAY_OF_YEAR, offset) + }.time + } + val weekLessons = data.lessons.filter { lesson -> + weekDays.any { WidgetDateUtils.isSameDay(lesson.start, it) } + } + val periods = trimTrailingPeriods(data.periods, weekLessons) + val grid = GridContext( + context = context, + packageName = packageName, + palette = palette, + periods = periods, + hourHeightDp = resolveHourHeight(size, chromeDp, periods), + tileInnerWidthDp = size?.let { + (it.width - CHROME_PADDING_H_DP - RULER_WIDTH_DP - WEEK_DIVIDERS_DP) / 5f - + tileInsetsDp() + }, + ) val views = RemoteViews(packageName, R.layout.widget_week) - applyChrome(views, palette, options) + applyChrome(views, palette, size) views.setTextColor(R.id.widget_week_title, palette.textPrimary) views.setTextColor(R.id.widget_week_subtitle, palette.textSecondary) val cal = Calendar.getInstance().apply { time = data.anchorDate } @@ -235,45 +427,36 @@ object WidgetRenderer { R.id.widget_week_col_fri, ) views.removeAllViews(R.id.widget_week_time_labels) - populateGridLines(packageName, views, R.id.widget_week_time_labels, hourHeightDp, palette, data.periods) - populateTimeLabels(packageName, views, R.id.widget_week_time_labels, hourHeightDp, palette, data.periods) - - val (weekWidthDp, _) = widgetSizeDp(options) - // Time-label column is 28dp wide; the rest is split across 5 days - // plus thin dividers (negligible). Drop room/teacher only on the - // very narrowest week widgets — autoSize handles the in-between - // sizes. - val dayColumnWidthDp = (weekWidthDp - 28f - 20f) / 5f - val subjectOnly = dayColumnWidthDp < WEEK_COLUMN_TIGHT_DP + populateRuler(grid, views, R.id.widget_week_time_labels) + val today = Date() for ((index, columnId) in columnIds.withIndex()) { views.removeAllViews(headerIds[index]) views.removeAllViews(columnId) - val day = Calendar.getInstance().apply { - time = data.anchorDate - add(Calendar.DAY_OF_YEAR, index) - }.time - val header = RemoteViews(packageName, R.layout.widget_week_day_header) - header.setTextColor(R.id.widget_week_day_header_weekday, palette.textPrimary) - header.setTextColor(R.id.widget_week_day_header_date, palette.textSecondary) - header.setTextViewText(R.id.widget_week_day_header_weekday, weekdayShort.format(day)) - header.setTextViewText(R.id.widget_week_day_header_date, dateShort.format(day)) - views.addView(headerIds[index], header) + val day = weekDays[index] + val isToday = WidgetDateUtils.isSameDay(day, today) + val holiday = data.days.firstOrNull { + it.isHoliday && WidgetDateUtils.isSameDay(it.date, day) + } + views.addView(headerIds[index], buildDayHeader(grid, day, isToday)) + views.setInt( + columnId, + "setBackgroundColor", + when { + holiday != null -> palette.holidayTint + isToday -> palette.todayTint + else -> 0 + }, + ) - populateGridLines(packageName, views, columnId, hourHeightDp, palette, data.periods) - populateBreakBlocks(packageName, views, columnId, hourHeightDp, palette, data.periods) - for (lesson in data.lessons.filter { WidgetDateUtils.isSameDay(it.start, day) }) { - addLessonBlock( - context = context, - packageName = packageName, - parent = views, - containerId = columnId, - lesson = lesson, - hourHeightDp = hourHeightDp, - periods = data.periods, - subjectOnly = subjectOnly, - horizontalPaddingDp = 3, - ) + if (holiday != null) { + views.addView(columnId, buildHolidayColumn(grid, holiday)) + } else { + populateGridLines(grid, views, columnId) + populateBreakBlocks(grid, views, columnId) + } + for (lesson in weekLessons.filter { WidgetDateUtils.isSameDay(it.start, day) }) { + addLessonBlock(grid, views, columnId, lesson) } } @@ -281,27 +464,90 @@ object WidgetRenderer { return views } - /// Pulls the launcher-reported widget size out of the AppWidget options - /// bundle. The grid now spans `totalVirtualMin` minutes (lessons + - /// preserved big breaks), so we divide by that instead of a fixed hour - /// count to keep tiles readable across different timetables. - private fun resolveHourHeight( - options: Bundle?, - chromeDp: Int, - totalVirtualMin: Int, - ): Float { - val virtualHours = (totalVirtualMin / 60.0f).coerceAtLeast(1f) - val rawHeightDp = options?.let { - max( - it.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, 0), - it.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, 0), + private fun trimTrailingPeriods( + periods: List, + lessons: List, + ): List { + if (lessons.isEmpty()) return periods + val cal = Calendar.getInstance() + val latestEnd = lessons.maxOf { lesson -> + cal.time = lesson.end + cal.get(Calendar.HOUR_OF_DAY) * 60 + cal.get(Calendar.MINUTE) + } + val lastUsed = periods.indexOfLast { it.startMinutes < latestEnd } + return periods.take(max(lastUsed + 1, MIN_VISIBLE_PERIODS)) + } + + private fun tileInsetsDp(): Float = 2 * (TILE_MARGIN_H_DP + TILE_PADDING_H_DP) + + private fun buildDayHeader(grid: GridContext, day: Date, isToday: Boolean): RemoteViews { + val palette = grid.palette + val header = RemoteViews(grid.packageName, R.layout.widget_week_day_header) + header.setTextViewText( + R.id.widget_week_day_header_weekday, + weekdayShort.format(day).uppercase(Locale.GERMAN), + ) + header.setTextColor( + R.id.widget_week_day_header_weekday, + if (isToday) palette.accent else palette.textSecondary, + ) + val dayOfMonth = Calendar.getInstance().apply { time = day } + .get(Calendar.DAY_OF_MONTH).toString() + if (isToday) { + val bold = SpannableString(dayOfMonth).apply { + setSpan(StyleSpan(Typeface.BOLD), 0, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + header.setTextViewText(R.id.widget_week_day_header_date, bold) + header.setTextColor(R.id.widget_week_day_header_date, palette.onAccent) + header.setInt( + R.id.widget_week_day_header_date, + "setBackgroundResource", + R.drawable.widget_today_circle, ) - } ?: 0 - if (rawHeightDp <= 0) return 32f - val gridHeightDp = (rawHeightDp - chromeDp) - .coerceAtLeast((MIN_HOUR_HEIGHT_DP * virtualHours).toInt()) - return (gridHeightDp.toFloat() / virtualHours) - .coerceIn(MIN_HOUR_HEIGHT_DP.toFloat(), MAX_HOUR_HEIGHT_DP.toFloat()) + header.setColorStateList( + R.id.widget_week_day_header_date, + "setBackgroundTintList", + ColorStateList.valueOf(palette.accent), + ) + } else { + header.setTextViewText(R.id.widget_week_day_header_date, dayOfMonth) + header.setTextColor(R.id.widget_week_day_header_date, palette.textPrimary) + header.setInt(R.id.widget_week_day_header_date, "setBackgroundResource", 0) + } + return header + } + + private fun buildHolidayColumn(grid: GridContext, holiday: WidgetDayInfo): RemoteViews { + val palette = grid.palette + val column = RemoteViews(grid.packageName, R.layout.widget_holiday_column) + column.setInt(R.id.widget_holiday_icon, "setColorFilter", palette.textPrimary) + column.setTextColor(R.id.widget_holiday_label, palette.textPrimary) + column.setTextColor(R.id.widget_holiday_name, palette.textPrimary) + column.setTextViewText(R.id.widget_holiday_name, holiday.holidayName ?: "") + val totalVirtualMin = grid.periods.lastOrNull()?.virtualEndMinutes + ?: FALLBACK_VIRTUAL_MINUTES + column.setViewLayoutWidth( + R.id.widget_holiday_name, + (grid.dp(totalVirtualMin.toFloat()) - 2 * HOLIDAY_NAME_INSET_DP).coerceAtLeast(40f), + TypedValue.COMPLEX_UNIT_DIP, + ) + return column + } + + /// The grid spans the periods' virtual minutes (lessons plus preserved + /// big breaks), so the per-hour height follows the actual timetable + /// instead of a fixed hour count. + private fun resolveHourHeight( + size: SizeDp?, + chromeDp: Float, + periods: List, + ): Float { + if (size == null) return FALLBACK_HOUR_HEIGHT_DP + val totalVirtualMin = periods.lastOrNull()?.virtualEndMinutes + ?: FALLBACK_VIRTUAL_MINUTES + val virtualHours = (totalVirtualMin / 60.0f).coerceAtLeast(1f) + return ((size.height - chromeDp) / virtualHours) + .coerceIn(MIN_HOUR_HEIGHT_DP, MAX_HOUR_HEIGHT_DP) } /// Real wall-clock minute → position on the virtual axis. Inside a @@ -343,74 +589,64 @@ object WidgetRenderer { return 0f } - /// Below this per-hour height the two-line label collapses to a single - /// period number — time + number overlap otherwise. - private const val TIME_LABEL_COMPACT_THRESHOLD_DP = 26f - - private fun populateTimeLabels( - packageName: String, - parent: RemoteViews, - containerId: Int, - hourHeightDp: Float, - palette: WidgetPalette, - periods: List, - ) { - val compact = hourHeightDp < TIME_LABEL_COMPACT_THRESHOLD_DP - for (period in periods) { - val label = RemoteViews(packageName, R.layout.widget_time_label) - label.setTextViewText(R.id.widget_time_label_number, "${period.name}.") - label.setTextViewText(R.id.widget_time_label_time, formatHm(period.startMinutes)) - if (compact) { - label.setViewVisibility(R.id.widget_time_label_time, View.GONE) - label.setTextViewTextSize( - R.id.widget_time_label_number, - TypedValue.COMPLEX_UNIT_SP, - 9f, - ) - label.setTextColor(R.id.widget_time_label_number, palette.textPrimary) - } else { - label.setViewVisibility(R.id.widget_time_label_time, View.VISIBLE) - label.setTextViewTextSize( - R.id.widget_time_label_number, - TypedValue.COMPLEX_UNIT_SP, - 7f, - ) - label.setTextColor(R.id.widget_time_label_number, palette.textSecondary) - } - label.setTextColor(R.id.widget_time_label_time, palette.textPrimary) - val topDp = period.virtualStartMinutes * hourHeightDp / 60.0f - label.setViewLayoutMargin( + private fun populateRuler(grid: GridContext, parent: RemoteViews, containerId: Int) { + populateGridLines(grid, parent, containerId) + val palette = grid.palette + for (period in grid.periods) { + val cellDp = grid.dp((period.virtualEndMinutes - period.virtualStartMinutes).toFloat()) + val label = RemoteViews(grid.packageName, R.layout.widget_time_label) + positionVertically( + label, R.id.widget_time_label_root, - RemoteViews.MARGIN_TOP, - topDp, - TypedValue.COMPLEX_UNIT_DIP, + grid.dp(period.virtualStartMinutes.toFloat()), + cellDp, ) + label.setTextViewText(R.id.widget_time_label_number, period.name) + label.setTextColor(R.id.widget_time_label_number, palette.textPrimary) + label.setTextViewTextSize( + R.id.widget_time_label_number, + TypedValue.COMPLEX_UNIT_DIP, + (cellDp * 0.3f).coerceIn(8f, 13f), + ) + val showTimes = cellDp >= RULER_TIMES_MIN_CELL_DP + for ((viewId, minutes) in listOf( + R.id.widget_time_label_start to period.startMinutes, + R.id.widget_time_label_end to period.endMinutes, + )) { + label.setViewVisibility(viewId, if (showTimes) View.VISIBLE else View.GONE) + label.setTextViewText(viewId, formatHm(minutes)) + label.setTextColor(viewId, palette.textSecondary) + label.setTextViewTextSize( + viewId, + TypedValue.COMPLEX_UNIT_DIP, + (cellDp * 0.19f).coerceIn(6f, 8.5f), + ) + } + parent.addView(containerId, label) + } + forEachBreak(grid) { topDp, heightDp -> + if (heightDp < BREAK_ICON_MIN_HEIGHT_DP) return@forEachBreak + val label = RemoteViews(grid.packageName, R.layout.widget_break_label) + positionVertically(label, R.id.widget_break_label_root, topDp, heightDp) + label.setInt(R.id.widget_break_label_icon, "setColorFilter", palette.textSecondary) parent.addView(containerId, label) } } - private fun populateGridLines( - packageName: String, - parent: RemoteViews, - containerId: Int, - hourHeightDp: Float, - palette: WidgetPalette, - periods: List, - ) { + private fun populateGridLines(grid: GridContext, parent: RemoteViews, containerId: Int) { // Lines at every period start + end, deduped by virtual minute so // adjacent periods share a line and big-break boundaries get both // upper and lower edges. val drawn = mutableSetOf() - for (period in periods) { + for (period in grid.periods) { for (virtualMin in listOf(period.virtualStartMinutes, period.virtualEndMinutes)) { if (!drawn.add(virtualMin)) continue - val line = RemoteViews(packageName, R.layout.widget_grid_line) - line.setInt(R.id.widget_grid_line_root, "setBackgroundColor", palette.divider) - val topDp = virtualMin * hourHeightDp / 60.0f + val line = RemoteViews(grid.packageName, R.layout.widget_grid_line) + line.setInt(R.id.widget_grid_line_root, "setBackgroundColor", grid.palette.divider) line.setViewLayoutMargin( R.id.widget_grid_line_root, RemoteViews.MARGIN_TOP, - topDp, + grid.dp(virtualMin.toFloat()), TypedValue.COMPLEX_UNIT_DIP, ) parent.addView(containerId, line) @@ -418,45 +654,46 @@ object WidgetRenderer { } } - /// Faint translucent block in any virtual gap between two periods — - /// only big breaks (Hofpause, Mittagspause) survive the mapper's - /// small-break collapse. - private fun populateBreakBlocks( - packageName: String, - parent: RemoteViews, - containerId: Int, - hourHeightDp: Float, - palette: WidgetPalette, - periods: List, - ) { - for (i in 0 until periods.size - 1) { - val curr = periods[i] - val next = periods[i + 1] - val virtualGap = next.virtualStartMinutes - curr.virtualEndMinutes - if (virtualGap <= 0) continue - val block = RemoteViews(packageName, R.layout.widget_break_block) - val topDp = curr.virtualEndMinutes * hourHeightDp / 60.0f - val heightDp = virtualGap * hourHeightDp / 60.0f - block.setViewLayoutMargin( - R.id.widget_break_block_root, - RemoteViews.MARGIN_TOP, - topDp, - TypedValue.COMPLEX_UNIT_DIP, - ) - block.setViewLayoutHeight( - R.id.widget_break_block_root, - heightDp, - TypedValue.COMPLEX_UNIT_DIP, - ) + /// Tinted band with a centred icon in any virtual gap between two + /// periods — only big breaks (Hofpause, Mittagspause) survive the + /// mapper's small-break collapse. + private fun populateBreakBlocks(grid: GridContext, parent: RemoteViews, containerId: Int) { + forEachBreak(grid) { topDp, heightDp -> + val block = RemoteViews(grid.packageName, R.layout.widget_break_block) + positionVertically(block, R.id.widget_break_block_root, topDp, heightDp) block.setInt( R.id.widget_break_block_root, "setBackgroundColor", - palette.breakBlock, + grid.palette.breakBlock, + ) + block.setInt(R.id.widget_break_block_icon, "setColorFilter", grid.palette.accent) + block.setViewVisibility( + R.id.widget_break_block_icon, + if (heightDp < BREAK_ICON_MIN_HEIGHT_DP) View.GONE else View.VISIBLE, ) parent.addView(containerId, block) } } + private inline fun forEachBreak(grid: GridContext, action: (topDp: Float, heightDp: Float) -> Unit) { + for (i in 0 until grid.periods.size - 1) { + val curr = grid.periods[i] + val virtualGap = grid.periods[i + 1].virtualStartMinutes - curr.virtualEndMinutes + if (virtualGap <= 0) continue + action(grid.dp(curr.virtualEndMinutes.toFloat()), grid.dp(virtualGap.toFloat())) + } + } + + private fun positionVertically(views: RemoteViews, viewId: Int, topDp: Float, heightDp: Float) { + views.setViewLayoutMargin( + viewId, + RemoteViews.MARGIN_TOP, + topDp, + TypedValue.COMPLEX_UNIT_DIP, + ) + views.setViewLayoutHeight(viewId, heightDp, TypedValue.COMPLEX_UNIT_DIP) + } + private fun formatHm(minutesSinceMidnight: Int): String { val h = minutesSinceMidnight / 60 val m = minutesSinceMidnight % 60 @@ -466,15 +703,15 @@ object WidgetRenderer { /// Overrides the chrome XML's `@color/widget_*` defaults when the user /// pins a fixed light/dark theme, and resizes the watermark M to match /// the current widget bounds. - private fun applyChrome(views: RemoteViews, palette: WidgetPalette, options: Bundle?) { + private fun applyChrome(views: RemoteViews, palette: WidgetPalette, size: SizeDp?) { views.setInt(R.id.widget_root, "setBackgroundColor", palette.background) views.setInt(R.id.widget_watermark, "setColorFilter", palette.textPrimary) views.setFloat(R.id.widget_watermark, "setAlpha", palette.watermarkAlpha) - val (widthDp, heightDp) = widgetSizeDp(options) // Sized to the longer edge so the M scales with widget resize. // Negative end/bottom margin lets a sliver tuck behind the edge. - val markSize = (max(widthDp, heightDp) * 0.8f).coerceIn(160f, 400f) + val longerEdge = size?.let { max(it.width, it.height) } ?: 220f + val markSize = (longerEdge * 0.8f).coerceIn(160f, 400f) val offsetEnd = -(markSize * 0.18f) val offsetBottom = -(markSize * 0.18f) views.setViewLayoutWidth( @@ -501,29 +738,11 @@ object WidgetRenderer { ) } - private fun widgetSizeDp(options: Bundle?): Pair { - if (options == null) return Pair(220, 220) - val width = max( - options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH, 0), - options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH, 0), - ).coerceAtLeast(140) - val height = max( - options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, 0), - options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, 0), - ).coerceAtLeast(140) - return Pair(width, height) - } - private fun addLessonBlock( - context: Context, - packageName: String, + grid: GridContext, parent: RemoteViews, containerId: Int, lesson: WidgetLesson, - hourHeightDp: Float, - periods: List, - subjectOnly: Boolean, - horizontalPaddingDp: Int, ) { val cal = Calendar.getInstance() cal.time = lesson.start @@ -532,46 +751,28 @@ object WidgetRenderer { val endMinutes = cal.get(Calendar.HOUR_OF_DAY) * 60 + cal.get(Calendar.MINUTE) val durationMinutes = (endMinutes - startMinutes).coerceAtLeast(15) - val virtualStart = realMinutesToVirtual(startMinutes, periods) - val virtualEnd = realMinutesToVirtual(startMinutes + durationMinutes, periods) + val virtualStart = realMinutesToVirtual(startMinutes, grid.periods) + val virtualEnd = realMinutesToVirtual(startMinutes + durationMinutes, grid.periods) if (virtualEnd <= virtualStart) return // Half the gap above + half below so the grid line under the tile // stays visible. - val topDp = virtualStart * hourHeightDp / 60.0f + LESSON_GAP_DP / 2f - val heightDp = ((virtualEnd - virtualStart) * hourHeightDp / 60.0f - LESSON_GAP_DP) - .coerceAtLeast(MIN_BLOCK_HEIGHT_DP.toFloat()) + val topDp = grid.dp(virtualStart) + LESSON_GAP_DP / 2f + val heightDp = (grid.dp(virtualEnd - virtualStart) - LESSON_GAP_DP) + .coerceAtLeast(MIN_BLOCK_HEIGHT_DP) - val block = RemoteViews(packageName, R.layout.widget_lesson_block) - block.setViewLayoutMargin( + val block = RemoteViews(grid.packageName, R.layout.widget_lesson_block) + positionVertically(block, R.id.widget_lesson_block_root, topDp, heightDp) + block.setColorStateList( R.id.widget_lesson_block_root, - RemoteViews.MARGIN_TOP, - topDp, - TypedValue.COMPLEX_UNIT_DIP, + "setBackgroundTintList", + ColorStateList.valueOf(lesson.color ?: fallbackColor(lesson)), ) - block.setViewLayoutHeight( - R.id.widget_lesson_block_root, - heightDp, - TypedValue.COMPLEX_UNIT_DIP, - ) - block.setInt( - R.id.widget_lesson_block_root, - "setBackgroundResource", - statusDrawable(lesson), + block.setViewVisibility( + R.id.widget_lesson_cancelled, + if (lesson.status == WidgetLessonStatus.CANCELLED) View.VISIBLE else View.GONE, ) - val density = context.resources.displayMetrics.density - val padXPx = (horizontalPaddingDp * density).toInt() - val padYPx = (3 * density).toInt() - block.setViewPadding( - R.id.widget_lesson_block_root, - padXPx, padYPx, padXPx, padYPx, - ) - - block.setTextViewText(R.id.widget_lesson_subject, subjectLabel(lesson)) - - // Separate fixed-size badge so the +N hint stays readable when - // autoSize shrinks the subject on narrow tiles. if (lesson.siblingCount > 0) { block.setTextViewText( R.id.widget_lesson_sibling_badge, @@ -582,82 +783,140 @@ object WidgetRenderer { block.setViewVisibility(R.id.widget_lesson_sibling_badge, View.GONE) } - val room = roomLabel(lesson) - val teacher = teacherLabel(lesson) - val noSecondaryContent = room.isNullOrEmpty() && teacher.isNullOrEmpty() - val hideSecondary = subjectOnly || - heightDp < BLOCK_SHOW_ROOM_MIN_DP || - noSecondaryContent - block.setViewVisibility( - R.id.widget_lesson_secondary_stack, - if (hideSecondary) View.GONE else View.VISIBLE, - ) - // Custom-events have no room/teacher → let the subject wrap to 2 lines - // so long titles don't autoshrink to nothing. - block.setInt( - R.id.widget_lesson_subject, - "setMaxLines", - if (noSecondaryContent) 2 else 1, - ) - when { - hideSecondary -> { - applyOptionalText(block, R.id.widget_lesson_room, null) - applyOptionalText(block, R.id.widget_lesson_teacher, null) - } - heightDp < BLOCK_SHOW_TEACHER_SEPARATE_MIN_DP -> { - applyOptionalText(block, R.id.widget_lesson_room, room) - applyOptionalText(block, R.id.widget_lesson_teacher, null) - } - else -> { - applyOptionalText(block, R.id.widget_lesson_room, room) - applyOptionalText(block, R.id.widget_lesson_teacher, teacher) - } - } - + applyTileText(grid, block, lesson, heightDp - 2 * TILE_PADDING_V_DP) parent.addView(containerId, block) } - private fun applyOptionalText(views: RemoteViews, viewId: Int, text: String?) { - if (text.isNullOrEmpty()) { - views.setViewVisibility(viewId, View.GONE) + /// Distributes the tile height like the in-app _TileContent: the title + /// comes first, then as many body lines as fit. The line boxes are what + /// caps the font size — autoSize fills each box and shrinks a line only + /// when its text is wider than the tile. + private fun applyTileText( + grid: GridContext, + block: RemoteViews, + lesson: WidgetLesson, + innerHeightDp: Float, + ) { + val metrics = grid.metrics + val title = subjectLabel(lesson) + val body = listOfNotNull(lesson.room, teacherLabel(lesson)).filter { it.isNotBlank() } + + var titleBoxDp = min(metrics.titleBoxDp, innerHeightDp) + val bodyCapacity = floor((innerHeightDp - titleBoxDp) / metrics.bodyBoxDp + 0.05f) + .toInt() + .coerceIn(0, metrics.maxBodyLines) + val shownBody = when { + bodyCapacity >= body.size -> body + // Rather share one line than drop the teacher entirely. + bodyCapacity == 1 -> listOf(body.joinToString(" · ")) + else -> emptyList() + } + // No room for a line below the title: the room moves next to it, + // unless the title already names it ("Hof Aufsicht"). + val inline = if (bodyCapacity == 0) { + body.firstOrNull()?.takeUnless { title.contains(it) } } else { - views.setTextViewText(viewId, text) - views.setViewVisibility(viewId, View.VISIBLE) + null + } + + val spareDp = innerHeightDp - titleBoxDp - shownBody.size * metrics.bodyBoxDp + val wrappedLineDp = if (spareDp >= titleBoxDp) wrappedTitleLineBox(grid, title) else null + if (wrappedLineDp != null) titleBoxDp = 2 * wrappedLineDp + + // Keep the last line clear of the "+N" badge when it reaches down + // into the badge's corner. + val contentDp = titleBoxDp + shownBody.size * metrics.bodyBoxDp + val badgeInsetPx = if ( + lesson.siblingCount > 0 && innerHeightDp - contentDp < BADGE_HEIGHT_DP + ) { + (BADGE_WIDTH_DP * grid.context.resources.displayMetrics.density).toInt() + } else { + 0 + } + val lastLineId = listOf( + R.id.widget_lesson_title_row, + R.id.widget_lesson_room, + R.id.widget_lesson_teacher, + )[shownBody.size] + block.setViewPadding(lastLineId, 0, 0, badgeInsetPx, 0) + + if (inline != null) { + block.setViewVisibility(R.id.widget_lesson_inline, View.VISIBLE) + block.setTextViewText(R.id.widget_lesson_inline, inline) + block.setTextViewTextSize( + R.id.widget_lesson_inline, + TypedValue.COMPLEX_UNIT_DIP, + metrics.bodyFontDp, + ) + } else { + block.setViewVisibility(R.id.widget_lesson_inline, View.GONE) + } + + block.setTextViewText(R.id.widget_lesson_subject, title) + block.setInt( + R.id.widget_lesson_subject, + "setMaxLines", + if (wrappedLineDp != null) 2 else 1, + ) + block.setViewLayoutHeight( + R.id.widget_lesson_subject, + titleBoxDp, + TypedValue.COMPLEX_UNIT_DIP, + ) + for ((index, viewId) in listOf( + R.id.widget_lesson_room, + R.id.widget_lesson_teacher, + ).withIndex()) { + val text = shownBody.getOrNull(index) + if (text == null) { + block.setViewVisibility(viewId, View.GONE) + continue + } + block.setViewVisibility(viewId, View.VISIBLE) + block.setTextViewText(viewId, text) + block.setViewLayoutHeight(viewId, metrics.bodyBoxDp, TypedValue.COMPLEX_UNIT_DIP) } } - /// Custom-events use the user-picked palette (orange/red/green/blue, - /// mirroring CustomTimetableColors). - private fun statusDrawable(lesson: WidgetLesson): Int { - if (lesson.status == WidgetLessonStatus.EVENT && lesson.customColor != null) { - return when (lesson.customColor) { - "orange" -> R.drawable.widget_lesson_block_event_orange - "red" -> R.drawable.widget_lesson_block_event_red - "green" -> R.drawable.widget_lesson_block_event_green - "blue" -> R.drawable.widget_lesson_block_event_blue - else -> R.drawable.widget_lesson_block_event_orange - } - } - return when (lesson.status) { - WidgetLessonStatus.CANCELLED -> R.drawable.widget_lesson_block_cancelled - WidgetLessonStatus.IRREGULAR -> R.drawable.widget_lesson_block_irregular - WidgetLessonStatus.TEACHER_CHANGED -> R.drawable.widget_lesson_block_teacher_changed - WidgetLessonStatus.PAST -> R.drawable.widget_lesson_block_past - WidgetLessonStatus.EVENT -> R.drawable.widget_lesson_block_event_orange - // ONGOING collapses into REGULAR — widgets only refresh every - // ~30min so "the current lesson" is stale most of the time and - // the visual highlight would mislead more than help. - WidgetLessonStatus.ONGOING, - WidgetLessonStatus.REGULAR -> R.drawable.widget_lesson_block_regular + /// Line box for a title wrapped onto two lines, or null when it should + /// stay on one: it fits there at a readable size, or it has no word + /// boundary to break at. The box is capped so the longest word still fits + /// the tile width — autoSize would otherwise pick a larger font and break + /// mid-word. + private fun wrappedTitleLineBox(grid: GridContext, title: String): Float? { + val widthDp = (grid.tileInnerWidthDp ?: return null) * WIDTH_ESTIMATE_SAFETY + val paint = TextPaint().apply { + typeface = titleTypeface + textSize = 100f } + fun widthPerFontDp(text: String) = paint.measureText(text) / 100f + if (widthPerFontDp(title) * TITLE_WRAP_BELOW_FONT_DP <= widthDp) return null + val words = title.split(Regex("(?<=[\\s-])")).map { it.trim() }.filter { it.isNotEmpty() } + if (words.size < 2) return null + val longestWordFontDp = widthDp / words.maxOf { widthPerFontDp(it) } + return min(grid.metrics.titleBoxDp, longestWordFontDp * LINE_BOX_PER_FONT) + } + + /// Only reached for payloads written before Dart shipped the resolved + /// tile colour; mirrors lesson_color.dart. + private fun fallbackColor(lesson: WidgetLesson): Int = when (lesson.status) { + WidgetLessonStatus.CANCELLED -> 0xFF000000.toInt() + WidgetLessonStatus.IRREGULAR -> 0xFF8F19B3.toInt() + WidgetLessonStatus.TEACHER_CHANGED -> 0xFF29639B.toInt() + WidgetLessonStatus.DUTY -> 0xFF00796B.toInt() + WidgetLessonStatus.EVENT -> + if (lesson.customColor != null) 0xFFFFA000.toInt() else 0xFF2E7D32.toInt() + // No time-based highlight: the widget only refreshes every ~30min, + // so "current" and "past" would be stale most of the time. + WidgetLessonStatus.PAST, + WidgetLessonStatus.ONGOING, + WidgetLessonStatus.REGULAR -> 0xFF993333.toInt() } private fun subjectLabel(lesson: WidgetLesson): String { return lesson.subjectShort.ifEmpty { lesson.subjectLong ?: "—" } } - private fun roomLabel(lesson: WidgetLesson): String? = lesson.room - private fun teacherLabel(lesson: WidgetLesson): String? = lesson.teacher ?: lesson.originalTeacher diff --git a/android/app/src/main/res/drawable/widget_icon_coffee.xml b/android/app/src/main/res/drawable/widget_icon_coffee.xml new file mode 100644 index 0000000..e02dade --- /dev/null +++ b/android/app/src/main/res/drawable/widget_icon_coffee.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/app/src/main/res/drawable/widget_icon_event.xml b/android/app/src/main/res/drawable/widget_icon_event.xml new file mode 100644 index 0000000..0a601af --- /dev/null +++ b/android/app/src/main/res/drawable/widget_icon_event.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/app/src/main/res/drawable/widget_icon_restaurant.xml b/android/app/src/main/res/drawable/widget_icon_restaurant.xml new file mode 100644 index 0000000..bc8913f --- /dev/null +++ b/android/app/src/main/res/drawable/widget_icon_restaurant.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/app/src/main/res/drawable/widget_lesson_block.xml b/android/app/src/main/res/drawable/widget_lesson_block.xml new file mode 100644 index 0000000..0f4e532 --- /dev/null +++ b/android/app/src/main/res/drawable/widget_lesson_block.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/widget_lesson_block_cancelled.xml b/android/app/src/main/res/drawable/widget_lesson_block_cancelled.xml deleted file mode 100644 index 7427de0..0000000 --- a/android/app/src/main/res/drawable/widget_lesson_block_cancelled.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - diff --git a/android/app/src/main/res/drawable/widget_lesson_block_event_blue.xml b/android/app/src/main/res/drawable/widget_lesson_block_event_blue.xml deleted file mode 100644 index f50c66a..0000000 --- a/android/app/src/main/res/drawable/widget_lesson_block_event_blue.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/android/app/src/main/res/drawable/widget_lesson_block_event_green.xml b/android/app/src/main/res/drawable/widget_lesson_block_event_green.xml deleted file mode 100644 index bedb7ac..0000000 --- a/android/app/src/main/res/drawable/widget_lesson_block_event_green.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/android/app/src/main/res/drawable/widget_lesson_block_event_orange.xml b/android/app/src/main/res/drawable/widget_lesson_block_event_orange.xml deleted file mode 100644 index 1cbb295..0000000 --- a/android/app/src/main/res/drawable/widget_lesson_block_event_orange.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/android/app/src/main/res/drawable/widget_lesson_block_irregular.xml b/android/app/src/main/res/drawable/widget_lesson_block_irregular.xml deleted file mode 100644 index b9bfdc5..0000000 --- a/android/app/src/main/res/drawable/widget_lesson_block_irregular.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/android/app/src/main/res/drawable/widget_lesson_block_ongoing.xml b/android/app/src/main/res/drawable/widget_lesson_block_ongoing.xml deleted file mode 100644 index 2bdab1c..0000000 --- a/android/app/src/main/res/drawable/widget_lesson_block_ongoing.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/android/app/src/main/res/drawable/widget_lesson_block_past.xml b/android/app/src/main/res/drawable/widget_lesson_block_past.xml deleted file mode 100644 index b25ab7c..0000000 --- a/android/app/src/main/res/drawable/widget_lesson_block_past.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/android/app/src/main/res/drawable/widget_lesson_block_regular.xml b/android/app/src/main/res/drawable/widget_lesson_block_regular.xml deleted file mode 100644 index ca78a54..0000000 --- a/android/app/src/main/res/drawable/widget_lesson_block_regular.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/android/app/src/main/res/drawable/widget_lesson_block_teacher_changed.xml b/android/app/src/main/res/drawable/widget_lesson_block_teacher_changed.xml deleted file mode 100644 index 58cb161..0000000 --- a/android/app/src/main/res/drawable/widget_lesson_block_teacher_changed.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/android/app/src/main/res/drawable/widget_lesson_cancelled_overlay.xml b/android/app/src/main/res/drawable/widget_lesson_cancelled_overlay.xml new file mode 100644 index 0000000..ed82b67 --- /dev/null +++ b/android/app/src/main/res/drawable/widget_lesson_cancelled_overlay.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/widget_lesson_cancelled_x.xml b/android/app/src/main/res/drawable/widget_lesson_cancelled_x.xml index d396613..b9a7ffe 100644 --- a/android/app/src/main/res/drawable/widget_lesson_cancelled_x.xml +++ b/android/app/src/main/res/drawable/widget_lesson_cancelled_x.xml @@ -5,7 +5,7 @@ android:viewportWidth="100" android:viewportHeight="100"> diff --git a/android/app/src/main/res/drawable/widget_lesson_block_event_red.xml b/android/app/src/main/res/drawable/widget_today_circle.xml similarity index 51% rename from android/app/src/main/res/drawable/widget_lesson_block_event_red.xml rename to android/app/src/main/res/drawable/widget_today_circle.xml index 9576750..34f2dd4 100644 --- a/android/app/src/main/res/drawable/widget_lesson_block_event_red.xml +++ b/android/app/src/main/res/drawable/widget_today_circle.xml @@ -1,6 +1,5 @@ - - + android:shape="oval"> + diff --git a/android/app/src/main/res/layout/widget_break_block.xml b/android/app/src/main/res/layout/widget_break_block.xml index 8cc7900..6804e0f 100644 --- a/android/app/src/main/res/layout/widget_break_block.xml +++ b/android/app/src/main/res/layout/widget_break_block.xml @@ -3,6 +3,14 @@ android:id="@+id/widget_break_block_root" android:layout_width="match_parent" android:layout_height="14dp" - android:layout_marginTop="0dp" - android:layout_marginStart="2dp" - android:layout_marginEnd="2dp" /> + android:layout_marginTop="0dp"> + + + diff --git a/android/app/src/main/res/layout/widget_break_label.xml b/android/app/src/main/res/layout/widget_break_label.xml new file mode 100644 index 0000000..505cf4d --- /dev/null +++ b/android/app/src/main/res/layout/widget_break_label.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/android/app/src/main/res/layout/widget_day.xml b/android/app/src/main/res/layout/widget_day.xml index 841d14e..32cf8c5 100644 --- a/android/app/src/main/res/layout/widget_day.xml +++ b/android/app/src/main/res/layout/widget_day.xml @@ -1,16 +1,18 @@ + the actual widget content without disturbing the LinearLayout flow. + Text sizes and chrome heights are dp: WidgetRenderer derives the grid + height from them, so they must not follow the system font scale. --> + android:background="@drawable/app_widget_background" + tools:ignore="SpUsage,SmallSp"> - + + android:paddingStart="8dp" + android:paddingEnd="8dp" + android:paddingTop="10dp" + android:paddingBottom="8dp"> + + + + + android:background="@color/widget_background" + tools:ignore="SpUsage,SmallSp,HardcodedText,UseAppTint"> + android:paddingStart="8dp" + android:paddingEnd="8dp" + android:paddingTop="10dp" + android:paddingBottom="8dp"> - - - - + android:layout_height="20dp" + android:gravity="start|center_vertical" + android:includeFontPadding="false" + android:textSize="14dp" + android:textStyle="bold" + android:textColor="@color/widget_text_primary" + android:maxLines="1" + android:text="Heute · 06.05." /> + android:layout_marginTop="4dp" + android:orientation="horizontal"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + - - - + + + + + + - - - + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/widget_holiday_column.xml b/android/app/src/main/res/layout/widget_holiday_column.xml new file mode 100644 index 0000000..130df81 --- /dev/null +++ b/android/app/src/main/res/layout/widget_holiday_column.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/widget_lesson_block.xml b/android/app/src/main/res/layout/widget_lesson_block.xml index 8cdbb39..e163555 100644 --- a/android/app/src/main/res/layout/widget_lesson_block.xml +++ b/android/app/src/main/res/layout/widget_lesson_block.xml @@ -1,90 +1,113 @@ + + android:background="@drawable/widget_lesson_block" + tools:ignore="SpUsage,SmallSp"> + android:orientation="vertical" + android:paddingStart="4dp" + android:paddingEnd="4dp" + android:paddingTop="2dp" + android:paddingBottom="2dp"> + + + + + + + + + android:autoSizeMinTextSize="5.5dp" + android:autoSizeMaxTextSize="10dp" + android:autoSizeStepGranularity="0.5dp" + tools:text="A12" /> - - - - - - + + + - + + tools:ignore="SpUsage,SmallSp"> + + - + android:maxLines="1" + android:includeFontPadding="false" + tools:text="08:40" /> + diff --git a/android/app/src/main/res/layout/widget_week.xml b/android/app/src/main/res/layout/widget_week.xml index da2babb..5a4b688 100644 --- a/android/app/src/main/res/layout/widget_week.xml +++ b/android/app/src/main/res/layout/widget_week.xml @@ -1,11 +1,18 @@ + + android:background="@drawable/app_widget_background" + tools:ignore="SpUsage,SmallSp"> + + android:paddingStart="8dp" + android:paddingEnd="8dp" + android:paddingTop="10dp" + android:paddingBottom="8dp"> + android:layout_height="32dp" + android:layout_marginTop="4dp" + android:orientation="horizontal"> + android:layout_width="30dp" + android:layout_height="match_parent" /> @@ -123,7 +141,7 @@ + + tools:ignore="SpUsage,SmallSp"> + android:fontFamily="sans-serif-medium" + android:textSize="9dp" + android:textColor="@color/widget_text_secondary" + android:maxLines="1" + android:includeFontPadding="false" + tools:text="MO." /> + android:layout_width="18dp" + android:layout_height="18dp" + android:layout_marginTop="1dp" + android:gravity="center" + android:textSize="11dp" + android:textColor="@color/widget_text_primary" + android:maxLines="1" + android:includeFontPadding="false" + tools:text="6" /> diff --git a/android/app/src/main/res/layout/widget_week_preview.xml b/android/app/src/main/res/layout/widget_week_preview.xml index 821eaaf..ba90ce6 100644 --- a/android/app/src/main/res/layout/widget_week_preview.xml +++ b/android/app/src/main/res/layout/widget_week_preview.xml @@ -1,10 +1,11 @@ - + + android:background="@color/widget_background" + tools:ignore="SpUsage,SmallSp,HardcodedText,UseAppTint"> + android:paddingStart="8dp" + android:paddingEnd="8dp" + android:paddingTop="10dp" + android:paddingBottom="8dp"> + + - - - - - - - + android:layout_height="32dp" + android:layout_marginTop="4dp" + android:orientation="horizontal"> + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - + android:layout_marginTop="2dp" + android:orientation="horizontal"> - - - - - - - - - - - + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/values-night/widget_colors.xml b/android/app/src/main/res/values-night/widget_colors.xml index d1856c7..4074646 100644 --- a/android/app/src/main/res/values-night/widget_colors.xml +++ b/android/app/src/main/res/values-night/widget_colors.xml @@ -4,4 +4,6 @@ #FFF1F1F1 #FFB0B0B0 #33FFFFFF + #FFFFB3AF + #32FFB3AF diff --git a/android/app/src/main/res/values-v31/widget_support.xml b/android/app/src/main/res/values-v31/widget_support.xml new file mode 100644 index 0000000..dc1b9fd --- /dev/null +++ b/android/app/src/main/res/values-v31/widget_support.xml @@ -0,0 +1,4 @@ + + + true + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 7d0ae4c..db7fd9b 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -6,6 +6,7 @@ Stundenplan und Vertretungen für die ganze Schulwoche. Keine Stunden Ferien + Schulfrei Bitte einloggen, um den Stundenplan zu laden Lade… Stand: %1$s diff --git a/android/app/src/main/res/values/widget_colors.xml b/android/app/src/main/res/values/widget_colors.xml index db5e003..a653f1a 100644 --- a/android/app/src/main/res/values/widget_colors.xml +++ b/android/app/src/main/res/values/widget_colors.xml @@ -1,17 +1,9 @@ - - #FF993333 - #FFC83333 - #FF993333 - #FF000000 - #FF8F19B3 - #FF29639B - #FF2E7D32 - #FFFCF7F5 #FF111111 #FF555555 #22000000 + #FF904A47 + #32904A47 diff --git a/android/app/src/main/res/values/widget_support.xml b/android/app/src/main/res/values/widget_support.xml new file mode 100644 index 0000000..82b7382 --- /dev/null +++ b/android/app/src/main/res/values/widget_support.xml @@ -0,0 +1,7 @@ + + + + false + diff --git a/ios/TimetableWidgetExtension/TimetableDayView.swift b/ios/TimetableWidgetExtension/TimetableDayView.swift index 7ab20e3..1817a33 100644 --- a/ios/TimetableWidgetExtension/TimetableDayView.swift +++ b/ios/TimetableWidgetExtension/TimetableDayView.swift @@ -1,59 +1,6 @@ import SwiftUI import WidgetKit -// Layout constants — must mirror WidgetRenderer.kt on Android, otherwise -// the platforms drift apart on the same widget size. -let FALLBACK_VIRTUAL_MINUTES = 11 * 60 -let MIN_HOUR_HEIGHT: CGFloat = 18 -let MAX_HOUR_HEIGHT: CGFloat = 72 -let MIN_BLOCK_HEIGHT: CGFloat = 16 -let LESSON_GAP: CGFloat = 1.5 - -func realMinutesToVirtual(_ realMin: Int, periods: [WidgetPeriod]) -> CGFloat { - guard !periods.isEmpty else { return CGFloat(realMin) } - for p in periods where realMin >= p.startMinutes && realMin <= p.endMinutes { - return CGFloat(p.virtualStartMinutes + (realMin - p.startMinutes)) - } - let first = periods.first! - if realMin < first.startMinutes { - return CGFloat(realMin - first.startMinutes + first.virtualStartMinutes) - } - let last = periods.last! - if realMin > last.endMinutes { - return CGFloat(last.virtualEndMinutes + (realMin - last.endMinutes)) - } - var prev = first - for i in 1.. prev.endMinutes && realMin < curr.startMinutes { - let gap = curr.startMinutes - prev.endMinutes - let virtualGap = curr.virtualStartMinutes - prev.virtualEndMinutes - if gap > 0 { - return CGFloat(prev.virtualEndMinutes) + - CGFloat(realMin - prev.endMinutes) * CGFloat(virtualGap) / CGFloat(gap) - } - return CGFloat(curr.virtualStartMinutes) - } - prev = curr - } - return 0 -} - -// Below these tile heights the secondary stack collapses progressively: -// shorter than ROOM_MIN drops everything, between ROOM_MIN and -// TEACHER_SEPARATE_MIN keeps the room but drops the teacher. -let BLOCK_SHOW_ROOM_MIN: CGFloat = 13 -let BLOCK_SHOW_TEACHER_SEPARATE_MIN: CGFloat = 20 - -let MIN_SUBJECT_FONT: CGFloat = 9 -let MAX_SUBJECT_FONT: CGFloat = 14 -let MIN_SECONDARY_FONT: CGFloat = 7 - -func subjectFont(forHourHeight hourHeight: CGFloat) -> CGFloat { - let t = max(0, min(1, (hourHeight - MIN_HOUR_HEIGHT) / (MAX_HOUR_HEIGHT - MIN_HOUR_HEIGHT))) - return MIN_SUBJECT_FONT + t * (MAX_SUBJECT_FONT - MIN_SUBJECT_FONT) -} - struct TimetableDayView: View { let entry: TimetableEntry @Environment(\.widgetPalette) private var palette @@ -73,7 +20,7 @@ struct TimetableDayView: View { @ViewBuilder private func content(data: WidgetTimetableData) -> some View { - VStack(alignment: .leading, spacing: 3) { + VStack(alignment: .leading, spacing: GRID_MARGIN_TOP) { header(data: data) if data.isHoliday { emptyState(text: data.holidayName ?? "Ferien") @@ -81,37 +28,41 @@ struct TimetableDayView: View { emptyState(text: "Keine Stunden") } else { GeometryReader { geo in - let totalMin = CGFloat(data.periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES) - TimeGridView( - lessons: data.lessons, - periods: data.periods, - hourHeight: max( - MIN_HOUR_HEIGHT, - min(MAX_HOUR_HEIGHT, geo.size.height / max(totalMin, 60) * 60) - ), - showRoom: true, - showTeacher: true, - showTimeLabels: true - ) + let periods = trimTrailingPeriods(data.periods, lessons: data.lessons) + HStack(alignment: .top, spacing: 0) { + PeriodRuler( + periods: periods, + hourHeight: resolveHourHeight(forGridHeight: geo.size.height, periods: periods) + ) + Rectangle() + .fill(palette.divider) + .frame(width: 1) + TimeGridView( + lessons: data.lessons, + periods: periods, + hourHeight: resolveHourHeight(forGridHeight: geo.size.height, periods: periods) + ) + } } } } } private func header(data: WidgetTimetableData) -> some View { - HStack(spacing: 4) { + HStack(spacing: 6) { Text(dayLabel(for: data.anchorDate, relativeTo: entry.date)) - .font(.system(size: 13, weight: .semibold)) + .font(.system(size: 14, weight: .bold)) .foregroundStyle(palette.textPrimary) .lineLimit(1) - .minimumScaleFactor(0.7) - Spacer(minLength: 4) + .minimumScaleFactor(0.65) + .frame(maxWidth: .infinity, alignment: .leading) Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))") - .font(.system(size: 9)) + .font(.system(size: 10)) .foregroundStyle(palette.textSecondary) .lineLimit(1) - .minimumScaleFactor(0.7) + .fixedSize() } + .frame(height: TITLE_ROW_HEIGHT) } private func emptyState(text: String) -> some View { @@ -139,54 +90,23 @@ struct TimetableDayView: View { } } -struct TimeGridView: View { - let lessons: [WidgetLesson] +/// Mirrors the in-app _PeriodLabel: period number centred, start time above, +/// end time below, a cup in the breaks. +struct PeriodRuler: View { let periods: [WidgetPeriod] let hourHeight: CGFloat - let showRoom: Bool - let showTeacher: Bool - let showTimeLabels: Bool - /// Week-widget passes 3 for narrow columns; day-widget keeps 7. - var horizontalPadding: CGFloat = 7 @Environment(\.widgetPalette) private var palette - private var totalVirtualMinutes: Int { - periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES - } - private var totalHeight: CGFloat { - CGFloat(totalVirtualMinutes) * hourHeight / 60.0 + CGFloat(totalVirtualMinutes(periods)) * hourHeight / 60.0 } - /// Below this per-hour height the two-line label collapses to a single - /// period number — time + number overlap otherwise. - private var compactLabels: Bool { hourHeight < 26 } - var body: some View { - HStack(alignment: .top, spacing: 0) { - if showTimeLabels { - // 28pt matches the Week widget's time-label column so the - // visual rhythm is identical across both widgets. - timeLabelsColumn - .frame(width: 28, alignment: .topTrailing) - Rectangle() - .fill(palette.divider) - .frame(width: 1) - } - ZStack(alignment: .top) { - gridLines - breakBlocks - ForEach(lessons.indices, id: \.self) { idx in - lessonBlock(lessons[idx]) - } - } - .frame(maxWidth: .infinity, minHeight: totalHeight, alignment: .top) - } - } - - private var timeLabelsColumn: some View { - ZStack(alignment: .topTrailing) { + // Every child has layout-y 0 (`.offset` only shifts the rendering), + // so both the ZStack and its frame need `.top` — the default + // `.center` would anchor the whole ruler at the column's midpoint. + ZStack(alignment: .top) { ForEach(periodBoundaries(periods), id: \.self) { virtualMin in Rectangle() .fill(palette.divider) @@ -194,39 +114,91 @@ struct TimeGridView: View { .offset(y: CGFloat(virtualMin) * hourHeight / 60.0) } ForEach(periods, id: \.startMinutes) { period in - VStack(alignment: .trailing, spacing: -2) { - if compactLabels { - Text("\(period.name).") - .font(.system(size: 9, weight: .bold)) - .foregroundStyle(palette.textPrimary) - .lineLimit(1) - } else { - Text(formatHm(period.startMinutes)) - .font(.system(size: 9)) - .foregroundStyle(palette.textPrimary) - .lineLimit(1) - Text("\(period.name).") - .font(.system(size: 7, weight: .bold)) - .foregroundStyle(palette.textSecondary) - .lineLimit(1) - } + cell(period) + } + ForEach(periodBreaks(periods), id: \.self) { pause in + let height = CGFloat(pause.virtualMinutes) * hourHeight / 60.0 + if height >= BREAK_ICON_MIN_HEIGHT { + Image(systemName: "cup.and.saucer") + .font(.system(size: 8)) + .foregroundStyle(palette.textSecondary) + .frame(width: RULER_WIDTH, height: height) + .offset(y: CGFloat(pause.virtualStart) * hourHeight / 60.0) } - .padding(.trailing, 4) - .offset(y: CGFloat(period.virtualStartMinutes) * hourHeight / 60.0) } } - .frame(height: totalHeight, alignment: .topTrailing) + .frame(width: RULER_WIDTH, height: totalHeight, alignment: .top) + } + + private func cell(_ period: WidgetPeriod) -> some View { + let height = CGFloat(period.virtualEndMinutes - period.virtualStartMinutes) * hourHeight / 60.0 + let timeFont = max(6, min(8.5, height * 0.19)) + return ZStack { + Text(period.name) + .font(.system(size: max(8, min(13, height * 0.3)), weight: .medium)) + .foregroundStyle(palette.textPrimary) + .lineLimit(1) + if height >= RULER_TIMES_MIN_CELL { + VStack(spacing: 0) { + Text(formatHm(period.startMinutes)) + Spacer(minLength: 0) + Text(formatHm(period.endMinutes)) + } + .font(.system(size: timeFont)) + .foregroundStyle(palette.textSecondary) + .lineLimit(1) + .padding(.vertical, 2) + } + } + .frame(width: RULER_WIDTH, height: height) + .offset(y: CGFloat(period.virtualStartMinutes) * hourHeight / 60.0) + } + + private func formatHm(_ minutes: Int) -> String { + String(format: "%02d:%02d", minutes / 60, minutes % 60) + } +} + +/// One day column: grid lines, break bands and the lesson tiles. Shared by +/// the day widget and each of the week widget's five columns. +struct TimeGridView: View { + let lessons: [WidgetLesson] + let periods: [WidgetPeriod] + let hourHeight: CGFloat + var holiday: WidgetDayInfo? = nil + var isToday: Bool = false + + @Environment(\.widgetPalette) private var palette + + private var totalHeight: CGFloat { + CGFloat(totalVirtualMinutes(periods)) * hourHeight / 60.0 + } + + var body: some View { + GeometryReader { geo in + let metrics = TileMetrics(periods: periods, hourHeight: hourHeight) + let innerWidth = geo.size.width - 2 * (TILE_MARGIN_H + TILE_PADDING_H) + ZStack(alignment: .top) { + if let holiday { + holidayColumn(holiday) + } else { + if isToday { + palette.todayTint.frame(height: totalHeight) + } + gridLines + breakBlocks + } + ForEach(lessons.indices, id: \.self) { idx in + lessonBlock(lessons[idx], metrics: metrics, innerWidth: innerWidth) + } + } + .frame(width: geo.size.width, height: totalHeight, alignment: .top) + } + .frame(height: totalHeight) } private var gridLines: some View { - // `.frame(height: totalHeight, alignment: .top)` — without the - // explicit `.top`, SwiftUI's frame modifier defaults to `.center`. - // Every Rectangle here has layout-y=0 (the `.offset()`s shift the - // rendered position only, not layout), so the inner ZStack's - // natural height is just 1pt; a 1pt ZStack centred in a 300pt - // frame anchors at the middle, and every line then renders - // relative to that midpoint — the grid visually starts halfway - // down the column and the later periods clip past the bottom. + // `.top` on both the ZStack and the frame — see PeriodRuler. ZStack(alignment: .top) { ForEach(periodBoundaries(periods), id: \.self) { virtualMin in Rectangle() @@ -239,166 +211,173 @@ struct TimeGridView: View { } private var breakBlocks: some View { - // Same `.center`-vs-`.top` bug as gridLines — see comment above. ZStack(alignment: .top) { - ForEach(0.. 0 { - Rectangle() - .fill(palette.breakBlock) - .frame(height: CGFloat(virtualGap) * hourHeight / 60.0) - .padding(.horizontal, 1) - .offset(y: CGFloat(curr.virtualEndMinutes) * hourHeight / 60.0) + ForEach(periodBreaks(periods), id: \.self) { pause in + let height = CGFloat(pause.virtualMinutes) * hourHeight / 60.0 + ZStack { + palette.breakBlock + if height >= BREAK_ICON_MIN_HEIGHT { + Image(systemName: "fork.knife") + .font(.system(size: 9)) + .foregroundStyle(palette.accent) + } } + .frame(height: height) + .offset(y: CGFloat(pause.virtualStart) * hourHeight / 60.0) } } .frame(height: totalHeight, alignment: .top) } - private func formatHm(_ minutes: Int) -> String { - String(format: "%02d:%02d", minutes / 60, minutes % 60) + /// Mirrors the in-app holiday region: icon and "Schulfrei" on top, the + /// holiday name rotated along the column. + private func holidayColumn(_ holiday: WidgetDayInfo) -> some View { + let nameLength = max(40, totalHeight - 2 * HOLIDAY_NAME_INSET) + return ZStack { + palette.holidayTint + VStack(spacing: 2) { + Image(systemName: "calendar") + .font(.system(size: 12)) + Text("Schulfrei") + .font(.system(size: 9)) + .lineLimit(1) + .minimumScaleFactor(0.6) + } + .padding(.top, 8) + .padding(.horizontal, 2) + .frame(maxHeight: .infinity, alignment: .top) + // The outer frame carries the rotated footprint: rotationEffect + // alone leaves the layout box at its unrotated size. + Text(holiday.holidayName ?? "") + .font(.system(size: 11, weight: .bold)) + .kerning(1) + .lineLimit(1) + .minimumScaleFactor(0.6) + .frame(width: nameLength, height: 14) + .rotationEffect(.degrees(90)) + .frame(width: 14, height: nameLength) + } + .foregroundStyle(palette.textPrimary) + .frame(height: totalHeight) } @ViewBuilder - private func lessonBlock(_ lesson: WidgetLesson) -> some View { - let cal = Calendar.current - let comps = cal.dateComponents([.hour, .minute], from: lesson.start) - let startMinutes = (comps.hour ?? 0) * 60 + (comps.minute ?? 0) + private func lessonBlock( + _ lesson: WidgetLesson, + metrics: TileMetrics, + innerWidth: CGFloat + ) -> some View { + let startMinutes = minutesOfDay(lesson.start) let durationMinutes = max(15, Int(lesson.end.timeIntervalSince(lesson.start) / 60)) let virtualStart = realMinutesToVirtual(startMinutes, periods: periods) let virtualEnd = realMinutesToVirtual(startMinutes + durationMinutes, periods: periods) if virtualEnd > virtualStart { + // Half the gap above + half below so the grid line under the + // tile stays visible. let top = virtualStart * hourHeight / 60.0 + LESSON_GAP / 2 let height = max( MIN_BLOCK_HEIGHT, (virtualEnd - virtualStart) * hourHeight / 60.0 - LESSON_GAP ) - let subjectSize = subjectFont(forHourHeight: hourHeight) - let secondarySize = max(MIN_SECONDARY_FONT, subjectSize - 2) - let room = lesson.room - let teacher = lesson.teacher ?? lesson.originalTeacher - let hasSecondary = (room?.isEmpty == false) || (teacher?.isEmpty == false) - HStack(alignment: .top, spacing: 4) { - Text(subjectLabel(lesson)) - .font(.system(size: subjectSize, weight: .semibold)) - .foregroundStyle(.white) - .lineLimit(hasSecondary ? 1 : 2) - .minimumScaleFactor(0.5) - if hasSecondary { - Spacer(minLength: 0) - VStack(alignment: .trailing, spacing: -1) { - if showRoom && height >= BLOCK_SHOW_ROOM_MIN { - if let room, !room.isEmpty { - Text(room) - .font(.system(size: secondarySize)) - .foregroundStyle(.white.opacity(0.85)) - .lineLimit(1) - .minimumScaleFactor(0.5) - } - if showTeacher, - height >= BLOCK_SHOW_TEACHER_SEPARATE_MIN, - let teacher, - !teacher.isEmpty { - Text(teacher) - .font(.system(size: secondarySize)) - .foregroundStyle(.white.opacity(0.7)) - .lineLimit(1) - .minimumScaleFactor(0.5) - } - } - } - } - } - .padding(.horizontal, horizontalPadding) - .padding(.vertical, 3) - .frame(maxWidth: .infinity, alignment: .topLeading) - .frame(height: height, alignment: .topLeading) - .background(blockColor(lesson)) - .cornerRadius(6) - .overlay(alignment: .bottomLeading) { - // Separate fixed-size badge so the +N hint stays readable - // when the subject autoshrinks on narrow tiles. - if let count = lesson.siblingCount, count > 0 { - Text("+\(count)") - .font(.system(size: 12, weight: .bold)) - .foregroundStyle(.white) - .padding(.leading, horizontalPadding) - .padding(.bottom, 2) - } - } - .overlay { - // CrossPainter parity: clip cross to the rounded shape so - // the diagonals don't bleed past the corners. - if lesson.status == .cancelled { - ZStack { - RoundedRectangle(cornerRadius: 6) - .stroke(Color.red.opacity(0.78), lineWidth: 1.5) - GeometryReader { geo in - Path { p in - p.move(to: .zero) - p.addLine(to: CGPoint(x: geo.size.width, y: geo.size.height)) - p.move(to: CGPoint(x: geo.size.width, y: 0)) - p.addLine(to: CGPoint(x: 0, y: geo.size.height)) - } - .stroke(Color.red.opacity(0.78), lineWidth: 3) - } - } - .clipShape(RoundedRectangle(cornerRadius: 6)) - } - } - .padding(.horizontal, showRoom ? 2 : 1) + LessonTile( + lesson: lesson, + height: height, + metrics: metrics, + layout: TileTextLayout( + lesson: lesson, + innerHeight: height - 2 * TILE_PADDING_V, + innerWidth: innerWidth, + metrics: metrics + ) + ) .offset(y: top) } } - - private func subjectLabel(_ lesson: WidgetLesson) -> String { - !lesson.subjectShort.isEmpty - ? lesson.subjectShort - : (lesson.subjectLong ?? "—") - } - - /// Mirrors lesson_color.dart + custom_event_colors.dart so the widget - /// matches the in-app calendar exactly. - private func blockColor(_ lesson: WidgetLesson) -> Color { - if lesson.status == .event, let custom = lesson.customColor { - switch custom { - case "orange": return Color(red: 239/255.0, green: 108/255.0, blue: 0/255.0) - case "red": return Color(red: 153/255.0, green: 51/255.0, blue: 51/255.0) - case "green": return Color(red: 76/255.0, green: 175/255.0, blue: 80/255.0) - case "blue": return Color(red: 33/255.0, green: 150/255.0, blue: 243/255.0) - default: break - } - } - switch lesson.status { - // `.ongoing` deliberately collapses into the regular red — the - // widget timeline only ticks every ~30min, so highlighting the - // "current" lesson would be stale most of the day and misleads - // more than it helps. - case .regular, .past, .ongoing: - return Color(red: 153/255.0, green: 51/255.0, blue: 51/255.0) - case .cancelled: return .black - case .irregular: return Color(red: 143/255.0, green: 25/255.0, blue: 179/255.0) - case .teacherChanged: return Color(red: 41/255.0, green: 99/255.0, blue: 155/255.0) - case .event: return Color(red: 239/255.0, green: 108/255.0, blue: 0/255.0) - case .duty: return Color(red: 0/255.0, green: 121/255.0, blue: 107/255.0) - } - } } -/// Period boundaries deduped: adjacent periods share a line, periods on -/// either side of a break get their own (bracketing the break block). -func periodBoundaries(_ periods: [WidgetPeriod]) -> [Int] { - var seen = Set() - var result: [Int] = [] - for p in periods { - for v in [p.virtualStartMinutes, p.virtualEndMinutes] { - if seen.insert(v).inserted { result.append(v) } +/// Mirrors the in-app AppointmentTile: title on top, room and teacher +/// stacked below, each line shrinking on its own to fit the tile width. +private struct LessonTile: View { + let lesson: WidgetLesson + let height: CGFloat + let metrics: TileMetrics + let layout: TileTextLayout + + private static let cancelledRed = + Color(red: 244 / 255, green: 67 / 255, blue: 54 / 255).opacity(200 / 255) + + var body: some View { + let shape = RoundedRectangle(cornerRadius: TILE_CORNER_RADIUS) + VStack(alignment: .leading, spacing: 0) { + titleRow + ForEach(Array(layout.body.enumerated()), id: \.offset) { index, line in + Text(line) + .font(.system(size: metrics.bodyFont)) + .lineLimit(1) + .minimumScaleFactor(MIN_BODY_LINE_FONT / metrics.bodyFont) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: metrics.bodyBox) + .padding(.trailing, badgeInset(line: index + 1)) + } } + .foregroundStyle(.white) + .padding(.horizontal, TILE_PADDING_H) + .padding(.vertical, TILE_PADDING_V) + .frame(maxWidth: .infinity, alignment: .topLeading) + .frame(height: height, alignment: .topLeading) + .background(lesson.tileColor) + .clipShape(shape) + .overlay(alignment: .bottomTrailing) { + if let count = lesson.siblingCount, count > 0 { + Text("+\(count)") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.white) + .padding(.trailing, TILE_PADDING_H) + .padding(.bottom, TILE_PADDING_V) + } + } + .overlay { + if lesson.status == .cancelled { + ZStack { + shape.strokeBorder(LessonTile.cancelledRed, lineWidth: 2) + GeometryReader { geo in + Path { p in + p.move(to: .zero) + p.addLine(to: CGPoint(x: geo.size.width, y: geo.size.height)) + p.move(to: CGPoint(x: geo.size.width, y: 0)) + p.addLine(to: CGPoint(x: 0, y: geo.size.height)) + } + .stroke(LessonTile.cancelledRed, lineWidth: 2) + } + } + .clipShape(shape) + } + } + .padding(.horizontal, TILE_MARGIN_H) + } + + private var titleRow: some View { + HStack(alignment: .center, spacing: 3) { + Text(layout.title) + .font(.system(size: layout.titleFont, weight: .medium)) + .lineLimit(layout.titleLines) + .minimumScaleFactor(min(1, MIN_TITLE_FONT / layout.titleFont)) + .frame(maxWidth: .infinity, alignment: .leading) + if let inline = layout.inline { + Text(inline) + .font(.system(size: metrics.bodyFont)) + .lineLimit(1) + .fixedSize() + } + } + .frame(height: layout.titleBox) + .padding(.trailing, badgeInset(line: 0)) + } + + private func badgeInset(line: Int) -> CGFloat { + layout.badgeLine == line ? BADGE_WIDTH : 0 } - return result.sorted() } /// Fixed-locale formatters cached once — DateFormatter setup is the diff --git a/ios/TimetableWidgetExtension/TimetableLayout.swift b/ios/TimetableWidgetExtension/TimetableLayout.swift new file mode 100644 index 0000000..b37b28a --- /dev/null +++ b/ios/TimetableWidgetExtension/TimetableLayout.swift @@ -0,0 +1,279 @@ +import SwiftUI +import UIKit + +// Layout constants and rules — must mirror WidgetRenderer.kt on Android, +// otherwise the platforms drift apart on the same widget size. +let FALLBACK_VIRTUAL_MINUTES = 11 * 60 +let FALLBACK_SLOT_MINUTES = 45 + +/// Trailing periods without lessons are cut off so the tiles get more +/// height — but never below this many periods, or a short day would blow up +/// to a handful of oversized tiles. +let MIN_VISIBLE_PERIODS = 6 + +let MIN_HOUR_HEIGHT: CGFloat = 18 +let MAX_HOUR_HEIGHT: CGFloat = 72 +let MIN_BLOCK_HEIGHT: CGFloat = 16 +let LESSON_GAP: CGFloat = 1.5 + +let TITLE_ROW_HEIGHT: CGFloat = 20 +let GRID_MARGIN_TOP: CGFloat = 4 +let WEEK_HEADER_HEIGHT: CGFloat = 32 +let RULER_WIDTH: CGFloat = 30 + +let TILE_MARGIN_H: CGFloat = 1 +let TILE_PADDING_H: CGFloat = 4 +let TILE_PADDING_V: CGFloat = 2 +let TILE_CORNER_RADIUS: CGFloat = 7 + +// In-app tile: 15sp title over 10sp body lines. The widget keeps that ratio +// and scales both so title + room + teacher fill one period. +let TITLE_TO_BODY_RATIO: CGFloat = 1.5 +let MAX_BODY_FONT: CGFloat = 10 +let MIN_BODY_FONT: CGFloat = 6.5 +let MIN_TITLE_FONT: CGFloat = 6.5 +let MIN_BODY_LINE_FONT: CGFloat = 5.5 +let LINE_BOX_PER_FONT: CGFloat = 1.2 + +/// If stacking room and teacher would push the body font below this, both +/// share one line instead so the text stays readable. +let STACKED_BODY_MIN_FONT: CGFloat = 7.5 + +/// A title that needs to shrink below this to fit on one line wraps onto a +/// second line instead, if the tile has the height for it. +let TITLE_WRAP_BELOW_FONT: CGFloat = 8 + +// Footprint of the "+N" badge in the tile's bottom trailing corner. +let BADGE_WIDTH: CGFloat = 16 +let BADGE_HEIGHT: CGFloat = 11 + +let RULER_TIMES_MIN_CELL: CGFloat = 30 +let BREAK_ICON_MIN_HEIGHT: CGFloat = 12 + +/// Space the holiday column keeps free above and below the rotated name for +/// the icon and the "Schulfrei" label. +let HOLIDAY_NAME_INSET: CGFloat = 44 + +func realMinutesToVirtual(_ realMin: Int, periods: [WidgetPeriod]) -> CGFloat { + guard !periods.isEmpty else { return CGFloat(realMin) } + for p in periods where realMin >= p.startMinutes && realMin <= p.endMinutes { + return CGFloat(p.virtualStartMinutes + (realMin - p.startMinutes)) + } + let first = periods.first! + if realMin < first.startMinutes { + return CGFloat(realMin - first.startMinutes + first.virtualStartMinutes) + } + let last = periods.last! + if realMin > last.endMinutes { + return CGFloat(last.virtualEndMinutes + (realMin - last.endMinutes)) + } + var prev = first + for i in 1.. prev.endMinutes && realMin < curr.startMinutes { + let gap = curr.startMinutes - prev.endMinutes + let virtualGap = curr.virtualStartMinutes - prev.virtualEndMinutes + if gap > 0 { + return CGFloat(prev.virtualEndMinutes) + + CGFloat(realMin - prev.endMinutes) * CGFloat(virtualGap) / CGFloat(gap) + } + return CGFloat(curr.virtualStartMinutes) + } + prev = curr + } + return 0 +} + +func minutesOfDay(_ date: Date) -> Int { + let comps = Calendar.current.dateComponents([.hour, .minute], from: date) + return (comps.hour ?? 0) * 60 + (comps.minute ?? 0) +} + +func trimTrailingPeriods(_ periods: [WidgetPeriod], lessons: [WidgetLesson]) -> [WidgetPeriod] { + guard let latestEnd = lessons.map({ minutesOfDay($0.end) }).max() else { return periods } + let lastUsed = periods.lastIndex { $0.startMinutes < latestEnd } ?? -1 + return Array(periods.prefix(max(lastUsed + 1, MIN_VISIBLE_PERIODS))) +} + +func totalVirtualMinutes(_ periods: [WidgetPeriod]) -> Int { + periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES +} + +func resolveHourHeight(forGridHeight height: CGFloat, periods: [WidgetPeriod]) -> CGFloat { + let virtualHours = max(CGFloat(totalVirtualMinutes(periods)) / 60, 1) + return max(MIN_HOUR_HEIGHT, min(MAX_HOUR_HEIGHT, height / virtualHours)) +} + +/// Period boundaries deduped: adjacent periods share a line, periods on +/// either side of a break get their own (bracketing the break block). +func periodBoundaries(_ periods: [WidgetPeriod]) -> [Int] { + var seen = Set() + var result: [Int] = [] + for p in periods { + for v in [p.virtualStartMinutes, p.virtualEndMinutes] { + if seen.insert(v).inserted { result.append(v) } + } + } + return result.sorted() +} + +struct PeriodBreak: Hashable { + let virtualStart: Int + let virtualMinutes: Int +} + +/// Only big breaks (Hofpause, Mittagspause) survive the mapper's small-break +/// collapse as a virtual gap between two periods. +func periodBreaks(_ periods: [WidgetPeriod]) -> [PeriodBreak] { + guard periods.count > 1 else { return [] } + return (0.. 0 + ? PeriodBreak(virtualStart: periods[i].virtualEndMinutes, virtualMinutes: gap) + : nil + } +} + +/// Font and line-box sizes shared by every tile of one widget, derived from +/// the height of a single period. +struct TileMetrics { + let maxBodyLines: Int + let bodyFont: CGFloat + let titleBox: CGFloat + let bodyBox: CGFloat + + init(periods: [WidgetPeriod], hourHeight: CGFloat) { + let slotMinutes = periods.first.map { $0.virtualEndMinutes - $0.virtualStartMinutes } + ?? FALLBACK_SLOT_MINUTES + let slotHeight = CGFloat(slotMinutes) * hourHeight / 60 + let inner = slotHeight - LESSON_GAP - 2 * TILE_PADDING_V + func font(lines: CGFloat) -> CGFloat { + inner / (LINE_BOX_PER_FONT * (TITLE_TO_BODY_RATIO + lines)) + } + maxBodyLines = font(lines: 2) < STACKED_BODY_MIN_FONT ? 1 : 2 + bodyFont = max(MIN_BODY_FONT, min(MAX_BODY_FONT, font(lines: CGFloat(maxBodyLines)))) + titleBox = bodyFont * TITLE_TO_BODY_RATIO * LINE_BOX_PER_FONT + bodyBox = bodyFont * LINE_BOX_PER_FONT + } +} + +/// Distributes the tile height like the in-app _TileContent: the title comes +/// first, then as many body lines as fit. +struct TileTextLayout { + let title: String + let titleBox: CGFloat + let titleFont: CGFloat + let titleLines: Int + let body: [String] + /// Set when there is no room for a line below the title: the room moves + /// next to it instead. + let inline: String? + /// Index of the line (0 = title row) that has to stay clear of the "+N" + /// badge, or nil when the text ends above it. + let badgeLine: Int? + + init(lesson: WidgetLesson, innerHeight: CGFloat, innerWidth: CGFloat, metrics: TileMetrics) { + title = !lesson.subjectShort.isEmpty ? lesson.subjectShort : (lesson.subjectLong ?? "—") + let candidates = [lesson.room, lesson.teacher ?? lesson.originalTeacher] + .compactMap { $0 } + .filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } + + let singleTitleBox = min(metrics.titleBox, innerHeight) + let capacity = max( + 0, + min(metrics.maxBodyLines, Int((innerHeight - singleTitleBox) / metrics.bodyBox + 0.05)) + ) + if capacity >= candidates.count { + body = candidates + } else if capacity == 1 { + // Rather share one line than drop the teacher entirely. + body = [candidates.joined(separator: " · ")] + } else { + body = [] + } + if capacity == 0, let first = candidates.first, !title.contains(first) { + inline = first + } else { + inline = nil + } + + let spare = innerHeight - singleTitleBox - CGFloat(body.count) * metrics.bodyBox + if spare >= singleTitleBox, + let wrappedLine = TileTextLayout.wrappedTitleLineBox( + title, innerWidth: innerWidth, metrics: metrics + ) { + titleBox = 2 * wrappedLine + titleFont = wrappedLine / LINE_BOX_PER_FONT + titleLines = 2 + } else { + titleBox = singleTitleBox + titleFont = singleTitleBox / LINE_BOX_PER_FONT + titleLines = 1 + } + + let content = titleBox + CGFloat(body.count) * metrics.bodyBox + let hasBadge = (lesson.siblingCount ?? 0) > 0 + badgeLine = hasBadge && innerHeight - content < BADGE_HEIGHT ? body.count : nil + } + + /// Line box for a title wrapped onto two lines, or nil when it should + /// stay on one: it fits there at a readable size, or it has no word + /// boundary to break at. The box is capped so the longest word still + /// fits the tile width. + private static func wrappedTitleLineBox( + _ title: String, + innerWidth: CGFloat, + metrics: TileMetrics + ) -> CGFloat? { + func widthPerFontPoint(_ text: String) -> CGFloat { + let font = UIFont.systemFont(ofSize: 100, weight: .medium) + return (text as NSString).size(withAttributes: [.font: font]).width / 100 + } + if widthPerFontPoint(title) * TITLE_WRAP_BELOW_FONT <= innerWidth { return nil } + let words = title + .replacingOccurrences(of: "-", with: "- ") + .split(separator: " ") + .map(String.init) + guard words.count > 1, let widest = words.map(widthPerFontPoint).max(), widest > 0 else { + return nil + } + return min(metrics.titleBox, innerWidth / widest * LINE_BOX_PER_FONT) + } +} + +extension Color { + /// Parses the `#RRGGBB` tile colour resolved in Dart. + init?(widgetHex hex: String?) { + guard let hex, hex.count == 7, hex.hasPrefix("#"), + let value = UInt32(hex.dropFirst(), radix: 16) else { + return nil + } + self.init( + red: Double((value >> 16) & 0xFF) / 255, + green: Double((value >> 8) & 0xFF) / 255, + blue: Double(value & 0xFF) / 255 + ) + } +} + +extension WidgetLesson { + var tileColor: Color { + if let resolved = Color(widgetHex: color) { return resolved } + // Only reached for payloads written before Dart shipped the resolved + // tile colour; mirrors lesson_color.dart. + switch status { + // No time-based highlight: the widget timeline only ticks every + // ~30min, so "current" and "past" would be stale most of the day. + case .regular, .past, .ongoing: + return Color(red: 153 / 255, green: 51 / 255, blue: 51 / 255) + case .cancelled: return .black + case .irregular: return Color(red: 143 / 255, green: 25 / 255, blue: 179 / 255) + case .teacherChanged: return Color(red: 41 / 255, green: 99 / 255, blue: 155 / 255) + case .duty: return Color(red: 0, green: 121 / 255, blue: 107 / 255) + case .event: + return customColor != nil + ? Color(red: 1, green: 160 / 255, blue: 0) + : Color(red: 46 / 255, green: 125 / 255, blue: 50 / 255) + } + } +} diff --git a/ios/TimetableWidgetExtension/TimetableWeekView.swift b/ios/TimetableWidgetExtension/TimetableWeekView.swift index 123b0d0..6a55873 100644 --- a/ios/TimetableWidgetExtension/TimetableWeekView.swift +++ b/ios/TimetableWidgetExtension/TimetableWeekView.swift @@ -20,36 +20,44 @@ struct TimetableWeekView: View { @ViewBuilder private func content(data: WidgetTimetableData) -> some View { + let cal = Calendar.current + let days = (0..<5).map { offset in + cal.date(byAdding: .day, value: offset, to: data.anchorDate) ?? data.anchorDate + } + let weekLessons = data.lessons.filter { lesson in + days.contains { cal.isDate(lesson.start, inSameDayAs: $0) } + } + let periods = trimTrailingPeriods(data.periods, lessons: weekLessons) VStack(alignment: .leading, spacing: 0) { header(data: data) - dayHeaderRow(data: data) + dayHeaderRow(days: days) + .padding(.top, GRID_MARGIN_TOP) GeometryReader { geo in - let totalMin = CGFloat(data.periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES) - let hourHeight = max( - MIN_HOUR_HEIGHT, - min(MAX_HOUR_HEIGHT, geo.size.height / max(totalMin, 60) * 60) - ) - let dayColumnWidth = (geo.size.width - 28 - 4) / 5 - // 45pt is the minimum where a 7pt-font room (e.g. "F2.04") - // plus the subject fits without `minimumScaleFactor` - // shrinking the subject into illegibility. - let subjectOnly = dayColumnWidth < 45 + let height = resolveHourHeight(forGridHeight: geo.size.height, periods: periods) HStack(alignment: .top, spacing: 0) { - timeLabelsColumn(hourHeight: hourHeight, periods: data.periods) - .frame(width: 28, alignment: .topTrailing) + PeriodRuler(periods: periods, hourHeight: height) columnDivider - ForEach(0..<5, id: \.self) { offset in - column( - data: data, - offset: offset, - hourHeight: hourHeight, - subjectOnly: subjectOnly + ForEach(days.indices, id: \.self) { index in + TimeGridView( + lessons: weekLessons.filter { + cal.isDate($0.start, inSameDayAs: days[index]) + }, + periods: periods, + hourHeight: height, + holiday: holiday(on: days[index], data: data), + isToday: cal.isDate(days[index], inSameDayAs: entry.date) ) - .frame(maxWidth: .infinity) - if offset < 4 { columnDivider } + if index < 4 { columnDivider } } } } + .padding(.top, 2) + } + } + + private func holiday(on day: Date, data: WidgetTimetableData) -> WidgetDayInfo? { + data.days?.first { + $0.isHoliday && Calendar.current.isDate($0.date, inSameDayAs: day) } } @@ -63,112 +71,55 @@ struct TimetableWeekView: View { let cal = Calendar.current let week = cal.component(.weekOfYear, from: data.anchorDate) let endDate = cal.date(byAdding: .day, value: 4, to: data.anchorDate) ?? data.anchorDate - return HStack { + return HStack(spacing: 6) { Text("KW \(week) · \(shortDate(data.anchorDate))–\(shortDate(endDate))") - .font(.system(size: 12, weight: .semibold)) + .font(.system(size: 14, weight: .bold)) .foregroundStyle(palette.textPrimary) .lineLimit(1) - .minimumScaleFactor(0.8) - Spacer() + .minimumScaleFactor(0.65) + .frame(maxWidth: .infinity, alignment: .leading) Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))") - .font(.system(size: 9)) + .font(.system(size: 10)) .foregroundStyle(palette.textSecondary) .lineLimit(1) + .fixedSize() } + .frame(height: TITLE_ROW_HEIGHT) } - private func dayHeaderRow(data: WidgetTimetableData) -> some View { - let cal = Calendar.current - // `.frame(maxWidth: .infinity)` is critical: without an explicit - // greedy width (or a true `Spacer()` inside), this HStack collapses - // to the sum of its fixed-width children in a `.leading` VStack - // and the 5 day-columns end up sharing ~5pt of width — the labels - // crunch to the left and stop aligning with the grid columns below. + /// Mirrors the in-app _DayHeaderCell: weekday in caps, day number below, + /// today inside an accent circle. + private func dayHeaderRow(days: [Date]) -> some View { // The fixed height keeps `columnDivider`'s vertically-flexible - // Rectangle from stealing space from the GeometryReader. - return HStack(spacing: 0) { - Spacer().frame(width: 28) + // Rectangle from stealing space from the GeometryReader below. + HStack(spacing: 0) { + Spacer().frame(width: RULER_WIDTH) columnDivider - ForEach(0..<5, id: \.self) { offset in - let day = cal.date(byAdding: .day, value: offset, to: data.anchorDate) ?? data.anchorDate - VStack(spacing: -1) { - Text(weekday(for: day)) - .font(.system(size: 10, weight: .bold)) - .foregroundStyle(palette.textPrimary) - Text(shortDate(day)) - .font(.system(size: 8)) - .foregroundStyle(palette.textSecondary) - } - .frame(maxWidth: .infinity) - if offset < 4 { columnDivider } + ForEach(days.indices, id: \.self) { index in + dayHeaderCell(days[index]) + .frame(maxWidth: .infinity) + if index < 4 { columnDivider } } } - // 20pt fits 10pt weekday + 8pt date with tight -1pt spacing. - .frame(maxWidth: .infinity, minHeight: 20, maxHeight: 20) + .frame(maxWidth: .infinity, minHeight: WEEK_HEADER_HEIGHT, maxHeight: WEEK_HEADER_HEIGHT) } - private func timeLabelsColumn(hourHeight: CGFloat, periods: [WidgetPeriod]) -> some View { - let totalMin = periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES - let totalHeight = CGFloat(totalMin) * hourHeight / 60.0 - // Mirrors TimeGridView.compactLabels — below 26pt/hour a stacked - // time+name no longer fits in one period slot and the two labels - // visually clip into the next slot. Drop to a single bold period - // number until there's room to read both lines. - let compact = hourHeight < 26 - return ZStack(alignment: .topTrailing) { - ForEach(periodBoundaries(periods), id: \.self) { virtualMin in - Rectangle() - .fill(palette.divider) - .frame(height: 1) - .offset(y: CGFloat(virtualMin) * hourHeight / 60.0) - } - ForEach(periods, id: \.startMinutes) { period in - VStack(alignment: .trailing, spacing: -2) { - if compact { - Text("\(period.name).") - .font(.system(size: 9, weight: .bold)) - .foregroundStyle(palette.textPrimary) - .lineLimit(1) - } else { - Text(String(format: "%02d:%02d", period.startMinutes / 60, period.startMinutes % 60)) - .font(.system(size: 9)) - .foregroundStyle(palette.textPrimary) - .lineLimit(1) - Text("\(period.name).") - .font(.system(size: 7, weight: .bold)) - .foregroundStyle(palette.textSecondary) - .lineLimit(1) - } - } - .padding(.trailing, 4) - .offset(y: CGFloat(period.virtualStartMinutes) * hourHeight / 60.0) - } + private func dayHeaderCell(_ day: Date) -> some View { + // `entry.date`, not `Date()` — WidgetKit archives entries ahead of + // time, the midnight entry has to mark its own day. + let isToday = Calendar.current.isDate(day, inSameDayAs: entry.date) + return VStack(spacing: 1) { + Text(WidgetDateFormatters.weekdayShort.string(from: day).uppercased()) + .font(.system(size: 9, weight: .medium)) + .foregroundStyle(isToday ? palette.accent : palette.textSecondary) + .lineLimit(1) + Text("\(Calendar.current.component(.day, from: day))") + .font(.system(size: 11, weight: isToday ? .bold : .regular)) + .foregroundStyle(isToday ? palette.onAccent : palette.textPrimary) + .lineLimit(1) + .frame(width: 18, height: 18) + .background(Circle().fill(isToday ? palette.accent : Color.clear)) } - .frame(height: totalHeight, alignment: .topTrailing) - } - - private func column( - data: WidgetTimetableData, - offset: Int, - hourHeight: CGFloat, - subjectOnly: Bool - ) -> some View { - let cal = Calendar.current - let day = cal.date(byAdding: .day, value: offset, to: data.anchorDate) ?? data.anchorDate - let lessonsForDay = data.lessons.filter { cal.isDate($0.start, inSameDayAs: day) } - return TimeGridView( - lessons: lessonsForDay, - periods: data.periods, - hourHeight: hourHeight, - showRoom: !subjectOnly, - showTeacher: !subjectOnly, - showTimeLabels: false, - horizontalPadding: 3 - ) - } - - private func weekday(for date: Date) -> String { - WidgetDateFormatters.weekdayShort.string(from: date) } private func placeholder(_ message: String) -> some View { diff --git a/ios/TimetableWidgetExtension/WidgetData.swift b/ios/TimetableWidgetExtension/WidgetData.swift index 721e8a4..6846f19 100644 --- a/ios/TimetableWidgetExtension/WidgetData.swift +++ b/ios/TimetableWidgetExtension/WidgetData.swift @@ -33,6 +33,9 @@ struct WidgetLesson: Codable { let originalTeacher: String? let status: WidgetLessonStatus let customColor: String? + // Tile colour resolved in Dart as "#RRGGBB" (subject colours, palettes); + // nil only for payloads written before the field existed. + let color: String? let siblingCount: Int? } diff --git a/ios/TimetableWidgetExtension/WidgetPalette.swift b/ios/TimetableWidgetExtension/WidgetPalette.swift index c197273..d6aafec 100644 --- a/ios/TimetableWidgetExtension/WidgetPalette.swift +++ b/ios/TimetableWidgetExtension/WidgetPalette.swift @@ -9,15 +9,27 @@ struct WidgetPalette { let textPrimary: Color let textSecondary: Color let divider: Color + let accent: Color + let onAccent: Color let breakBlock: Color + let todayTint: Color + let holidayTint: Color let watermarkOpacity: Double + // ColorScheme.fromSeed(#993333).primary of the app's light / dark theme. + private static let lightAccent = Color(red: 0x90 / 255, green: 0x4A / 255, blue: 0x47 / 255) + private static let darkAccent = Color(red: 0xFF / 255, green: 0xB3 / 255, blue: 0xAF / 255) + static let light = WidgetPalette( background: Color(red: 0xFC / 255, green: 0xF7 / 255, blue: 0xF5 / 255), textPrimary: Color(red: 0x11 / 255, green: 0x11 / 255, blue: 0x11 / 255), textSecondary: Color(red: 0x55 / 255, green: 0x55 / 255, blue: 0x55 / 255), divider: Color.black.opacity(0x22 / 255.0), - breakBlock: Color.black.opacity(0x0C / 255.0), + accent: lightAccent, + onAccent: .white, + breakBlock: lightAccent.opacity(0x32 / 255.0), + todayTint: lightAccent.opacity(0x0E / 255.0), + holidayTint: Color.black.opacity(0x32 / 255.0), watermarkOpacity: 0.014 ) @@ -26,7 +38,11 @@ struct WidgetPalette { textPrimary: Color(red: 0xF1 / 255, green: 0xF1 / 255, blue: 0xF1 / 255), textSecondary: Color(red: 0xB0 / 255, green: 0xB0 / 255, blue: 0xB0 / 255), divider: Color.white.opacity(0x33 / 255.0), - breakBlock: Color.white.opacity(0x14 / 255.0), + accent: darkAccent, + onAccent: Color(red: 0x57 / 255, green: 0x1D / 255, blue: 0x1D / 255), + breakBlock: darkAccent.opacity(0x32 / 255.0), + todayTint: darkAccent.opacity(0x0E / 255.0), + holidayTint: Color.white.opacity(0x32 / 255.0), watermarkOpacity: 0.025 ) diff --git a/lib/widget_data/widget_data.dart b/lib/widget_data/widget_data.dart index 8ccd1a4..af0704d 100644 --- a/lib/widget_data/widget_data.dart +++ b/lib/widget_data/widget_data.dart @@ -14,9 +14,6 @@ enum WidgetLessonStatus { irregular, teacherChanged, event, - // Sonder-Lesson-Types (Aufsicht, Sprechstunde, …) — Native-Widget-Renderer, - // die den Status noch nicht kennen, fallen still auf den regulären Stil - // zurück, sobald der String dort unbekannt ist. duty, } @@ -35,6 +32,9 @@ abstract class WidgetLesson with _$WidgetLesson { String? originalTeacher, required WidgetLessonStatus status, String? customColor, + /// Fertig aufgelöste Kachelfarbe als `#RRGGBB`. Native Renderer kennen die + /// Paletten nicht — Änderungen daran brauchen so keinen nativen Eingriff. + String? color, @Default(0) int siblingCount, }) = _WidgetLesson; diff --git a/lib/widget_data/widget_data.freezed.dart b/lib/widget_data/widget_data.freezed.dart index 08f085c..0b01dda 100644 --- a/lib/widget_data/widget_data.freezed.dart +++ b/lib/widget_data/widget_data.freezed.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND // coverage:ignore-file -// ignore_for_file: type=lint +// ignore_for_file: type=lint, type=warning, deprecated_member_use, deprecated_member_use_from_same_package // ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark part of 'widget_data.dart'; @@ -9,6 +9,7 @@ part of 'widget_data.dart'; // FreezedGenerator // ************************************************************************** +// GENERATED CODE - DO NOT MODIFY BY HAND // dart format off T _$identity(T value) => value; @@ -18,7 +19,9 @@ mixin _$WidgetLesson { DateTime get start; DateTime get end; String get subjectShort; String? get subjectLong; String? get room;/// On teacher accounts this carries the class label ("7a") instead of the /// teacher short name — see `WidgetDataMapper` `showClassInsteadOfTeacher`; /// [originalTeacher] is null in that case. - String? get teacher; String? get originalTeacher; WidgetLessonStatus get status; String? get customColor; int get siblingCount; + String? get teacher; String? get originalTeacher; WidgetLessonStatus get status; String? get customColor;/// Fertig aufgelöste Kachelfarbe als `#RRGGBB`. Native Renderer kennen die +/// Paletten nicht — Änderungen daran brauchen so keinen nativen Eingriff. + String? get color; int get siblingCount; /// Create a copy of WidgetLesson /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -31,16 +34,21 @@ $WidgetLessonCopyWith get copyWith => _$WidgetLessonCopyWithImpl Object.hash(runtimeType,start,end,subjectShort,subjectLong,room,teacher,originalTeacher,status,customColor,siblingCount); +int get hashCode { + final _this = this as WidgetLesson; + return Object.hash(runtimeType,_this.start,_this.end,_this.subjectShort,_this.subjectLong,_this.room,_this.teacher,_this.originalTeacher,_this.status,_this.customColor,_this.color,_this.siblingCount); +} @override String toString() { - return 'WidgetLesson(start: $start, end: $end, subjectShort: $subjectShort, subjectLong: $subjectLong, room: $room, teacher: $teacher, originalTeacher: $originalTeacher, status: $status, customColor: $customColor, siblingCount: $siblingCount)'; + final _this = this as WidgetLesson; + return 'WidgetLesson(start: ${_this.start}, end: ${_this.end}, subjectShort: ${_this.subjectShort}, subjectLong: ${_this.subjectLong}, room: ${_this.room}, teacher: ${_this.teacher}, originalTeacher: ${_this.originalTeacher}, status: ${_this.status}, customColor: ${_this.customColor}, color: ${_this.color}, siblingCount: ${_this.siblingCount})'; } @@ -51,7 +59,7 @@ abstract mixin class $WidgetLessonCopyWith<$Res> { factory $WidgetLessonCopyWith(WidgetLesson value, $Res Function(WidgetLesson) _then) = _$WidgetLessonCopyWithImpl; @useResult $Res call({ - DateTime start, DateTime end, String subjectShort, String? subjectLong, String? room, String? teacher, String? originalTeacher, WidgetLessonStatus status, String? customColor, int siblingCount + DateTime start, DateTime end, String subjectShort, String? subjectLong, String? room, String? teacher, String? originalTeacher, WidgetLessonStatus status, String? customColor, String? color, int siblingCount }); @@ -68,8 +76,8 @@ class _$WidgetLessonCopyWithImpl<$Res> /// Create a copy of WidgetLesson /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? start = null,Object? end = null,Object? subjectShort = null,Object? subjectLong = freezed,Object? room = freezed,Object? teacher = freezed,Object? originalTeacher = freezed,Object? status = null,Object? customColor = freezed,Object? siblingCount = null,}) { - return _then(_self.copyWith( +@pragma('vm:prefer-inline') @override $Res call({Object? start = null,Object? end = null,Object? subjectShort = null,Object? subjectLong = freezed,Object? room = freezed,Object? teacher = freezed,Object? originalTeacher = freezed,Object? status = null,Object? customColor = freezed,Object? color = freezed,Object? siblingCount = null,}) { + return _then(WidgetLesson( start: null == start ? _self.start : start // ignore: cast_nullable_to_non_nullable as DateTime,end: null == end ? _self.end : end // ignore: cast_nullable_to_non_nullable as DateTime,subjectShort: null == subjectShort ? _self.subjectShort : subjectShort // ignore: cast_nullable_to_non_nullable @@ -79,6 +87,7 @@ as String?,teacher: freezed == teacher ? _self.teacher : teacher // ignore: cast as String?,originalTeacher: freezed == originalTeacher ? _self.originalTeacher : originalTeacher // ignore: cast_nullable_to_non_nullable as String?,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable as WidgetLessonStatus,customColor: freezed == customColor ? _self.customColor : customColor // ignore: cast_nullable_to_non_nullable +as String?,color: freezed == color ? _self.color : color // ignore: cast_nullable_to_non_nullable as String?,siblingCount: null == siblingCount ? _self.siblingCount : siblingCount // ignore: cast_nullable_to_non_nullable as int, )); @@ -165,10 +174,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( DateTime start, DateTime end, String subjectShort, String? subjectLong, String? room, String? teacher, String? originalTeacher, WidgetLessonStatus status, String? customColor, int siblingCount)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( DateTime start, DateTime end, String subjectShort, String? subjectLong, String? room, String? teacher, String? originalTeacher, WidgetLessonStatus status, String? customColor, String? color, int siblingCount)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _WidgetLesson() when $default != null: -return $default(_that.start,_that.end,_that.subjectShort,_that.subjectLong,_that.room,_that.teacher,_that.originalTeacher,_that.status,_that.customColor,_that.siblingCount);case _: +return $default(_that.start,_that.end,_that.subjectShort,_that.subjectLong,_that.room,_that.teacher,_that.originalTeacher,_that.status,_that.customColor,_that.color,_that.siblingCount);case _: return orElse(); } @@ -186,10 +195,10 @@ return $default(_that.start,_that.end,_that.subjectShort,_that.subjectLong,_that /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( DateTime start, DateTime end, String subjectShort, String? subjectLong, String? room, String? teacher, String? originalTeacher, WidgetLessonStatus status, String? customColor, int siblingCount) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( DateTime start, DateTime end, String subjectShort, String? subjectLong, String? room, String? teacher, String? originalTeacher, WidgetLessonStatus status, String? customColor, String? color, int siblingCount) $default,) {final _that = this; switch (_that) { case _WidgetLesson(): -return $default(_that.start,_that.end,_that.subjectShort,_that.subjectLong,_that.room,_that.teacher,_that.originalTeacher,_that.status,_that.customColor,_that.siblingCount);case _: +return $default(_that.start,_that.end,_that.subjectShort,_that.subjectLong,_that.room,_that.teacher,_that.originalTeacher,_that.status,_that.customColor,_that.color,_that.siblingCount);case _: throw StateError('Unexpected subclass'); } @@ -206,10 +215,10 @@ return $default(_that.start,_that.end,_that.subjectShort,_that.subjectLong,_that /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( DateTime start, DateTime end, String subjectShort, String? subjectLong, String? room, String? teacher, String? originalTeacher, WidgetLessonStatus status, String? customColor, int siblingCount)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( DateTime start, DateTime end, String subjectShort, String? subjectLong, String? room, String? teacher, String? originalTeacher, WidgetLessonStatus status, String? customColor, String? color, int siblingCount)? $default,) {final _that = this; switch (_that) { case _WidgetLesson() when $default != null: -return $default(_that.start,_that.end,_that.subjectShort,_that.subjectLong,_that.room,_that.teacher,_that.originalTeacher,_that.status,_that.customColor,_that.siblingCount);case _: +return $default(_that.start,_that.end,_that.subjectShort,_that.subjectLong,_that.room,_that.teacher,_that.originalTeacher,_that.status,_that.customColor,_that.color,_that.siblingCount);case _: return null; } @@ -221,7 +230,7 @@ return $default(_that.start,_that.end,_that.subjectShort,_that.subjectLong,_that @JsonSerializable() class _WidgetLesson implements WidgetLesson { - const _WidgetLesson({required this.start, required this.end, required this.subjectShort, this.subjectLong, this.room, this.teacher, this.originalTeacher, required this.status, this.customColor, this.siblingCount = 0}); + const _WidgetLesson({required this.start, required this.end, required this.subjectShort, this.subjectLong, this.room, this.teacher, this.originalTeacher, required this.status, this.customColor, this.color, this.siblingCount = 0}); factory _WidgetLesson.fromJson(Map json) => _$WidgetLessonFromJson(json); @override final DateTime start; @@ -236,6 +245,9 @@ class _WidgetLesson implements WidgetLesson { @override final String? originalTeacher; @override final WidgetLessonStatus status; @override final String? customColor; +/// Fertig aufgelöste Kachelfarbe als `#RRGGBB`. Native Renderer kennen die +/// Paletten nicht — Änderungen daran brauchen so keinen nativen Eingriff. +@override final String? color; @override@JsonKey() final int siblingCount; /// Create a copy of WidgetLesson @@ -251,16 +263,18 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _WidgetLesson&&(identical(other.start, start) || other.start == start)&&(identical(other.end, end) || other.end == end)&&(identical(other.subjectShort, subjectShort) || other.subjectShort == subjectShort)&&(identical(other.subjectLong, subjectLong) || other.subjectLong == subjectLong)&&(identical(other.room, room) || other.room == room)&&(identical(other.teacher, teacher) || other.teacher == teacher)&&(identical(other.originalTeacher, originalTeacher) || other.originalTeacher == originalTeacher)&&(identical(other.status, status) || other.status == status)&&(identical(other.customColor, customColor) || other.customColor == customColor)&&(identical(other.siblingCount, siblingCount) || other.siblingCount == siblingCount)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _WidgetLesson&&(identical(other.start, start) || other.start == start)&&(identical(other.end, end) || other.end == end)&&(identical(other.subjectShort, subjectShort) || other.subjectShort == subjectShort)&&(identical(other.subjectLong, subjectLong) || other.subjectLong == subjectLong)&&(identical(other.room, room) || other.room == room)&&(identical(other.teacher, teacher) || other.teacher == teacher)&&(identical(other.originalTeacher, originalTeacher) || other.originalTeacher == originalTeacher)&&(identical(other.status, status) || other.status == status)&&(identical(other.customColor, customColor) || other.customColor == customColor)&&(identical(other.color, color) || other.color == color)&&(identical(other.siblingCount, siblingCount) || other.siblingCount == siblingCount)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,start,end,subjectShort,subjectLong,room,teacher,originalTeacher,status,customColor,siblingCount); +int get hashCode { + return Object.hash(runtimeType,start,end,subjectShort,subjectLong,room,teacher,originalTeacher,status,customColor,color,siblingCount); +} @override String toString() { - return 'WidgetLesson(start: $start, end: $end, subjectShort: $subjectShort, subjectLong: $subjectLong, room: $room, teacher: $teacher, originalTeacher: $originalTeacher, status: $status, customColor: $customColor, siblingCount: $siblingCount)'; + return 'WidgetLesson(start: $start, end: $end, subjectShort: $subjectShort, subjectLong: $subjectLong, room: $room, teacher: $teacher, originalTeacher: $originalTeacher, status: $status, customColor: $customColor, color: $color, siblingCount: $siblingCount)'; } @@ -271,7 +285,7 @@ abstract mixin class _$WidgetLessonCopyWith<$Res> implements $WidgetLessonCopyWi factory _$WidgetLessonCopyWith(_WidgetLesson value, $Res Function(_WidgetLesson) _then) = __$WidgetLessonCopyWithImpl; @override @useResult $Res call({ - DateTime start, DateTime end, String subjectShort, String? subjectLong, String? room, String? teacher, String? originalTeacher, WidgetLessonStatus status, String? customColor, int siblingCount + DateTime start, DateTime end, String subjectShort, String? subjectLong, String? room, String? teacher, String? originalTeacher, WidgetLessonStatus status, String? customColor, String? color, int siblingCount }); @@ -288,7 +302,7 @@ class __$WidgetLessonCopyWithImpl<$Res> /// Create a copy of WidgetLesson /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? start = null,Object? end = null,Object? subjectShort = null,Object? subjectLong = freezed,Object? room = freezed,Object? teacher = freezed,Object? originalTeacher = freezed,Object? status = null,Object? customColor = freezed,Object? siblingCount = null,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? start = null,Object? end = null,Object? subjectShort = null,Object? subjectLong = freezed,Object? room = freezed,Object? teacher = freezed,Object? originalTeacher = freezed,Object? status = null,Object? customColor = freezed,Object? color = freezed,Object? siblingCount = null,}) { return _then(_WidgetLesson( start: null == start ? _self.start : start // ignore: cast_nullable_to_non_nullable as DateTime,end: null == end ? _self.end : end // ignore: cast_nullable_to_non_nullable @@ -299,6 +313,7 @@ as String?,teacher: freezed == teacher ? _self.teacher : teacher // ignore: cast as String?,originalTeacher: freezed == originalTeacher ? _self.originalTeacher : originalTeacher // ignore: cast_nullable_to_non_nullable as String?,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable as WidgetLessonStatus,customColor: freezed == customColor ? _self.customColor : customColor // ignore: cast_nullable_to_non_nullable +as String?,color: freezed == color ? _self.color : color // ignore: cast_nullable_to_non_nullable as String?,siblingCount: null == siblingCount ? _self.siblingCount : siblingCount // ignore: cast_nullable_to_non_nullable as int, )); @@ -332,16 +347,21 @@ $WidgetPeriodCopyWith get copyWith => _$WidgetPeriodCopyWithImpl Object.hash(runtimeType,name,startMinutes,endMinutes,virtualStartMinutes,virtualEndMinutes); +int get hashCode { + final _this = this as WidgetPeriod; + return Object.hash(runtimeType,_this.name,_this.startMinutes,_this.endMinutes,_this.virtualStartMinutes,_this.virtualEndMinutes); +} @override String toString() { - return 'WidgetPeriod(name: $name, startMinutes: $startMinutes, endMinutes: $endMinutes, virtualStartMinutes: $virtualStartMinutes, virtualEndMinutes: $virtualEndMinutes)'; + final _this = this as WidgetPeriod; + return 'WidgetPeriod(name: ${_this.name}, startMinutes: ${_this.startMinutes}, endMinutes: ${_this.endMinutes}, virtualStartMinutes: ${_this.virtualStartMinutes}, virtualEndMinutes: ${_this.virtualEndMinutes})'; } @@ -370,7 +390,7 @@ class _$WidgetPeriodCopyWithImpl<$Res> /// Create a copy of WidgetPeriod /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({Object? name = null,Object? startMinutes = null,Object? endMinutes = null,Object? virtualStartMinutes = null,Object? virtualEndMinutes = null,}) { - return _then(_self.copyWith( + return _then(WidgetPeriod( name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable as String,startMinutes: null == startMinutes ? _self.startMinutes : startMinutes // ignore: cast_nullable_to_non_nullable as int,endMinutes: null == endMinutes ? _self.endMinutes : endMinutes // ignore: cast_nullable_to_non_nullable @@ -547,16 +567,18 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _WidgetPeriod&&(identical(other.name, name) || other.name == name)&&(identical(other.startMinutes, startMinutes) || other.startMinutes == startMinutes)&&(identical(other.endMinutes, endMinutes) || other.endMinutes == endMinutes)&&(identical(other.virtualStartMinutes, virtualStartMinutes) || other.virtualStartMinutes == virtualStartMinutes)&&(identical(other.virtualEndMinutes, virtualEndMinutes) || other.virtualEndMinutes == virtualEndMinutes)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _WidgetPeriod&&(identical(other.name, name) || other.name == name)&&(identical(other.startMinutes, startMinutes) || other.startMinutes == startMinutes)&&(identical(other.endMinutes, endMinutes) || other.endMinutes == endMinutes)&&(identical(other.virtualStartMinutes, virtualStartMinutes) || other.virtualStartMinutes == virtualStartMinutes)&&(identical(other.virtualEndMinutes, virtualEndMinutes) || other.virtualEndMinutes == virtualEndMinutes)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,name,startMinutes,endMinutes,virtualStartMinutes,virtualEndMinutes); +int get hashCode { + return Object.hash(runtimeType,name,startMinutes,endMinutes,virtualStartMinutes,virtualEndMinutes); +} @override String toString() { - return 'WidgetPeriod(name: $name, startMinutes: $startMinutes, endMinutes: $endMinutes, virtualStartMinutes: $virtualStartMinutes, virtualEndMinutes: $virtualEndMinutes)'; + return 'WidgetPeriod(name: $name, startMinutes: $startMinutes, endMinutes: $endMinutes, virtualStartMinutes: $virtualStartMinutes, virtualEndMinutes: $virtualEndMinutes)'; } @@ -615,16 +637,21 @@ $WidgetDayInfoCopyWith get copyWith => _$WidgetDayInfoCopyWithImp @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is WidgetDayInfo&&(identical(other.date, date) || other.date == date)&&(identical(other.isHoliday, isHoliday) || other.isHoliday == isHoliday)&&(identical(other.holidayName, holidayName) || other.holidayName == holidayName)); + final _this = this as WidgetDayInfo; + return identical(this, other) || (other.runtimeType == runtimeType&&other is WidgetDayInfo&&(identical(other.date, _this.date) || other.date == _this.date)&&(identical(other.isHoliday, _this.isHoliday) || other.isHoliday == _this.isHoliday)&&(identical(other.holidayName, _this.holidayName) || other.holidayName == _this.holidayName)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,date,isHoliday,holidayName); +int get hashCode { + final _this = this as WidgetDayInfo; + return Object.hash(runtimeType,_this.date,_this.isHoliday,_this.holidayName); +} @override String toString() { - return 'WidgetDayInfo(date: $date, isHoliday: $isHoliday, holidayName: $holidayName)'; + final _this = this as WidgetDayInfo; + return 'WidgetDayInfo(date: ${_this.date}, isHoliday: ${_this.isHoliday}, holidayName: ${_this.holidayName})'; } @@ -653,7 +680,7 @@ class _$WidgetDayInfoCopyWithImpl<$Res> /// Create a copy of WidgetDayInfo /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({Object? date = null,Object? isHoliday = null,Object? holidayName = freezed,}) { - return _then(_self.copyWith( + return _then(WidgetDayInfo( date: null == date ? _self.date : date // ignore: cast_nullable_to_non_nullable as DateTime,isHoliday: null == isHoliday ? _self.isHoliday : isHoliday // ignore: cast_nullable_to_non_nullable as bool,holidayName: freezed == holidayName ? _self.holidayName : holidayName // ignore: cast_nullable_to_non_nullable @@ -818,16 +845,18 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _WidgetDayInfo&&(identical(other.date, date) || other.date == date)&&(identical(other.isHoliday, isHoliday) || other.isHoliday == isHoliday)&&(identical(other.holidayName, holidayName) || other.holidayName == holidayName)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _WidgetDayInfo&&(identical(other.date, date) || other.date == date)&&(identical(other.isHoliday, isHoliday) || other.isHoliday == isHoliday)&&(identical(other.holidayName, holidayName) || other.holidayName == holidayName)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,date,isHoliday,holidayName); +int get hashCode { + return Object.hash(runtimeType,date,isHoliday,holidayName); +} @override String toString() { - return 'WidgetDayInfo(date: $date, isHoliday: $isHoliday, holidayName: $holidayName)'; + return 'WidgetDayInfo(date: $date, isHoliday: $isHoliday, holidayName: $holidayName)'; } @@ -888,16 +917,21 @@ $WidgetTimetableDataCopyWith get copyWith => _$WidgetTimeta @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is WidgetTimetableData&&(identical(other.fetchedAt, fetchedAt) || other.fetchedAt == fetchedAt)&&(identical(other.anchorDate, anchorDate) || other.anchorDate == anchorDate)&&const DeepCollectionEquality().equals(other.lessons, lessons)&&const DeepCollectionEquality().equals(other.periods, periods)&&(identical(other.isHoliday, isHoliday) || other.isHoliday == isHoliday)&&(identical(other.holidayName, holidayName) || other.holidayName == holidayName)&&const DeepCollectionEquality().equals(other.days, days)); + final _this = this as WidgetTimetableData; + return identical(this, other) || (other.runtimeType == runtimeType&&other is WidgetTimetableData&&(identical(other.fetchedAt, _this.fetchedAt) || other.fetchedAt == _this.fetchedAt)&&(identical(other.anchorDate, _this.anchorDate) || other.anchorDate == _this.anchorDate)&&const DeepCollectionEquality().equals(other.lessons, _this.lessons)&&const DeepCollectionEquality().equals(other.periods, _this.periods)&&(identical(other.isHoliday, _this.isHoliday) || other.isHoliday == _this.isHoliday)&&(identical(other.holidayName, _this.holidayName) || other.holidayName == _this.holidayName)&&const DeepCollectionEquality().equals(other.days, _this.days)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,fetchedAt,anchorDate,const DeepCollectionEquality().hash(lessons),const DeepCollectionEquality().hash(periods),isHoliday,holidayName,const DeepCollectionEquality().hash(days)); +int get hashCode { + final _this = this as WidgetTimetableData; + return Object.hash(runtimeType,_this.fetchedAt,_this.anchorDate,const DeepCollectionEquality().hash(_this.lessons),const DeepCollectionEquality().hash(_this.periods),_this.isHoliday,_this.holidayName,const DeepCollectionEquality().hash(_this.days)); +} @override String toString() { - return 'WidgetTimetableData(fetchedAt: $fetchedAt, anchorDate: $anchorDate, lessons: $lessons, periods: $periods, isHoliday: $isHoliday, holidayName: $holidayName, days: $days)'; + final _this = this as WidgetTimetableData; + return 'WidgetTimetableData(fetchedAt: ${_this.fetchedAt}, anchorDate: ${_this.anchorDate}, lessons: ${_this.lessons}, periods: ${_this.periods}, isHoliday: ${_this.isHoliday}, holidayName: ${_this.holidayName}, days: ${_this.days})'; } @@ -926,7 +960,7 @@ class _$WidgetTimetableDataCopyWithImpl<$Res> /// Create a copy of WidgetTimetableData /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({Object? fetchedAt = null,Object? anchorDate = null,Object? lessons = null,Object? periods = null,Object? isHoliday = null,Object? holidayName = freezed,Object? days = null,}) { - return _then(_self.copyWith( + return _then(WidgetTimetableData( fetchedAt: null == fetchedAt ? _self.fetchedAt : fetchedAt // ignore: cast_nullable_to_non_nullable as DateTime,anchorDate: null == anchorDate ? _self.anchorDate : anchorDate // ignore: cast_nullable_to_non_nullable as DateTime,lessons: null == lessons ? _self.lessons : lessons // ignore: cast_nullable_to_non_nullable @@ -1075,7 +1109,7 @@ return $default(_that.fetchedAt,_that.anchorDate,_that.lessons,_that.periods,_th @JsonSerializable() class _WidgetTimetableData implements WidgetTimetableData { - const _WidgetTimetableData({required this.fetchedAt, required this.anchorDate, required final List lessons, final List periods = const [], this.isHoliday = false, this.holidayName, final List days = const []}): _lessons = lessons,_periods = periods,_days = days; + const _WidgetTimetableData({required this.fetchedAt, required this.anchorDate, required List lessons, List periods = const [], this.isHoliday = false, this.holidayName, List days = const []}): _lessons = lessons,_periods = periods,_days = days; factory _WidgetTimetableData.fromJson(Map json) => _$WidgetTimetableDataFromJson(json); @override final DateTime fetchedAt; @@ -1122,16 +1156,18 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _WidgetTimetableData&&(identical(other.fetchedAt, fetchedAt) || other.fetchedAt == fetchedAt)&&(identical(other.anchorDate, anchorDate) || other.anchorDate == anchorDate)&&const DeepCollectionEquality().equals(other._lessons, _lessons)&&const DeepCollectionEquality().equals(other._periods, _periods)&&(identical(other.isHoliday, isHoliday) || other.isHoliday == isHoliday)&&(identical(other.holidayName, holidayName) || other.holidayName == holidayName)&&const DeepCollectionEquality().equals(other._days, _days)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _WidgetTimetableData&&(identical(other.fetchedAt, fetchedAt) || other.fetchedAt == fetchedAt)&&(identical(other.anchorDate, anchorDate) || other.anchorDate == anchorDate)&&const DeepCollectionEquality().equals(other.lessons, _lessons)&&const DeepCollectionEquality().equals(other.periods, _periods)&&(identical(other.isHoliday, isHoliday) || other.isHoliday == isHoliday)&&(identical(other.holidayName, holidayName) || other.holidayName == holidayName)&&const DeepCollectionEquality().equals(other.days, _days)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,fetchedAt,anchorDate,const DeepCollectionEquality().hash(_lessons),const DeepCollectionEquality().hash(_periods),isHoliday,holidayName,const DeepCollectionEquality().hash(_days)); +int get hashCode { + return Object.hash(runtimeType,fetchedAt,anchorDate,const DeepCollectionEquality().hash(_lessons),const DeepCollectionEquality().hash(_periods),isHoliday,holidayName,const DeepCollectionEquality().hash(_days)); +} @override String toString() { - return 'WidgetTimetableData(fetchedAt: $fetchedAt, anchorDate: $anchorDate, lessons: $lessons, periods: $periods, isHoliday: $isHoliday, holidayName: $holidayName, days: $days)'; + return 'WidgetTimetableData(fetchedAt: $fetchedAt, anchorDate: $anchorDate, lessons: $lessons, periods: $periods, isHoliday: $isHoliday, holidayName: $holidayName, days: $days)'; } diff --git a/lib/widget_data/widget_data.g.dart b/lib/widget_data/widget_data.g.dart index 6a836c3..de71751 100644 --- a/lib/widget_data/widget_data.g.dart +++ b/lib/widget_data/widget_data.g.dart @@ -17,6 +17,7 @@ _WidgetLesson _$WidgetLessonFromJson(Map json) => originalTeacher: json['originalTeacher'] as String?, status: $enumDecode(_$WidgetLessonStatusEnumMap, json['status']), customColor: json['customColor'] as String?, + color: json['color'] as String?, siblingCount: (json['siblingCount'] as num?)?.toInt() ?? 0, ); @@ -31,6 +32,7 @@ Map _$WidgetLessonToJson(_WidgetLesson instance) => 'originalTeacher': instance.originalTeacher, 'status': _$WidgetLessonStatusEnumMap[instance.status]!, 'customColor': instance.customColor, + 'color': instance.color, 'siblingCount': instance.siblingCount, }; diff --git a/lib/widget_data/widget_data_mapper.dart b/lib/widget_data/widget_data_mapper.dart index 1e2bd1a..79a37dd 100644 --- a/lib/widget_data/widget_data_mapper.dart +++ b/lib/widget_data/widget_data_mapper.dart @@ -1,4 +1,5 @@ import 'dart:developer'; +import 'dart:ui'; import '../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart'; import '../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms_response.dart'; @@ -9,11 +10,13 @@ import '../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart'; import '../extensions/date_time.dart'; import '../utils/recurrence_occurrences.dart'; +import '../view/pages/timetable/data/lesson_color.dart'; import '../view/pages/timetable/data/lesson_labels.dart'; import '../view/pages/timetable/data/lesson_merger.dart'; import '../view/pages/timetable/data/lesson_period_schedule.dart'; import '../view/pages/timetable/data/lesson_status.dart'; import '../view/pages/timetable/data/lesson_type_label.dart'; +import '../view/pages/timetable/data/subject_color_palette.dart'; import 'widget_data.dart'; class WidgetDataMapper { @@ -322,22 +325,26 @@ class WidgetDataMapper { }) { final start = lesson.startDateTime; final end = lesson.endDateTime; - final status = _mapStatus( - LessonStatusClassifier.classify(lesson, start, end, now), - ); final subjectShortRaw = lesson.subjects.firstOrNull?.trim() ?? ''; - // Webuntis sometimes ships subject-less entries (Wandertag etc.). Prefer - // their free text, else "Event" so the tile isn't just a dash. + // Gleiche Einstufung wie TimetableAppointmentFactory, damit Aufsicht, + // Prüfung & Co. im Widget dieselbe Farbe tragen wie in der App. + final lessonStatus = LessonStatusClassifier.classify( + lesson, + start, + end, + now, + isEvent: subjectShortRaw.isEmpty && lesson.lessonType == 'LESSON', + isDuty: lesson.lessonType != 'LESSON', + ); final subjectShort = subjectShortRaw.isEmpty - ? LessonTypeLabel.entryText(lesson) ?? LessonTypeLabel.fallback + ? LessonTypeLabel.forEntry(lesson) : subjectShortRaw; - String? subjectLong; - if (subjects != null && subjectShortRaw.isNotEmpty) { - subjectLong = subjects.result - .where((s) => s.shortName == subjectShortRaw) - .firstOrNull - ?.longName; - } + final subject = subjects == null || subjectShortRaw.isEmpty + ? null + : subjects.result + .where((s) => s.shortName == subjectShortRaw) + .firstOrNull; + final subjectLong = subject?.longName; final roomShort = lesson.rooms.firstOrNull; var roomName = roomShort; if (rooms != null && roomShort != null) { @@ -365,10 +372,29 @@ class WidgetDataMapper { room: roomName, teacher: teacherName, originalTeacher: originalTeacher, - status: status, + status: _mapStatus(lessonStatus), + color: _hex(_lessonColor(lessonStatus, subject?.color)), ); } + /// Das Widget kennt keine Zeitzustände: laufende Stunden bekommen dieselbe + /// Farbe wie reguläre, sonst würde die Hervorhebung zwischen zwei + /// Aktualisierungen veralten. + static Color _lessonColor(LessonStatus status, String? subjectColor) { + final isRegular = + status == LessonStatus.regular || + status == LessonStatus.ongoing || + status == LessonStatus.past; + if (!isRegular) return LessonColor.forStatus(status); + return SubjectColorPalette.colorFromName(subjectColor) ?? + LessonColor.regular; + } + + static String _hex(Color color) { + final rgb = color.toARGB32() & 0xFFFFFF; + return '#${rgb.toRadixString(16).padLeft(6, '0').toUpperCase()}'; + } + static WidgetLessonStatus _mapStatus(LessonStatus status) { switch (status) { case LessonStatus.cancelled: @@ -480,6 +506,12 @@ class WidgetDataMapper { subjectLong: title.isEmpty ? null : title, status: WidgetLessonStatus.event, customColor: event.color, + color: _hex( + SubjectColorPalette.colorFromName(event.color) ?? + SubjectColorPalette.displayOf( + SubjectColorPalette.defaultColor, + ).color, + ), ); final isAllDay = duration == Duration.zero && _isMidnight(event.startDate); diff --git a/test/widget_data/widget_data_mapper_test.dart b/test/widget_data/widget_data_mapper_test.dart index 91fa69c..c23d286 100644 --- a/test/widget_data/widget_data_mapper_test.dart +++ b/test/widget_data/widget_data_mapper_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart'; +import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart'; import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; import 'package:marianum_mobile/api/mhsl/custom_timetable_event/custom_timetable_event.dart'; import 'package:marianum_mobile/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart'; @@ -33,6 +34,8 @@ McTimetableEntry _lesson({ String? teacherName, String? teacherOrgname, String? substText, + String lessonType = 'LESSON', + List classNames = const [], }) => McTimetableEntry( id: 1, date: date, @@ -50,8 +53,8 @@ McTimetableEntry _lesson({ ] : const [], rooms: room != null ? [room] : const [], - classNames: const [], - lessonType: 'LESSON', + classNames: classNames, + lessonType: lessonType, status: status, substitutionText: substText, lessonText: null, @@ -117,8 +120,18 @@ void main() { test('only includes lessons on the anchor day', () { final lessons = [ - _lesson(date: DateTime(2026, 5, 6), startHhmm: 800, endHhmm: 845, subjectName: 'MA'), - _lesson(date: DateTime(2026, 5, 7), startHhmm: 800, endHhmm: 845, subjectName: 'EN'), + _lesson( + date: DateTime(2026, 5, 6), + startHhmm: 800, + endHhmm: 845, + subjectName: 'MA', + ), + _lesson( + date: DateTime(2026, 5, 7), + startHhmm: 800, + endHhmm: 845, + subjectName: 'EN', + ), ]; final data = WidgetDataMapper.buildDayData( now: now, @@ -164,14 +177,11 @@ void main() { rooms: null, holidays: null, ); - expect( - data.lessons.map((l) => l.status).toList(), - [ - WidgetLessonStatus.cancelled, - WidgetLessonStatus.irregular, - WidgetLessonStatus.teacherChanged, - ], - ); + expect(data.lessons.map((l) => l.status).toList(), [ + WidgetLessonStatus.cancelled, + WidgetLessonStatus.irregular, + WidgetLessonStatus.teacherChanged, + ]); }); test('marks day as holiday when in holiday range', () { @@ -224,10 +234,11 @@ void main() { rooms: null, holidays: null, ); - expect( - data.lessons.map((l) => l.subjectShort).toList(), - ['MA', 'EN', 'BIO'], - ); + expect(data.lessons.map((l) => l.subjectShort).toList(), [ + 'MA', + 'EN', + 'BIO', + ]); }); }); @@ -236,9 +247,24 @@ void main() { test('long event bumps every regular lesson it covers', () { final lessons = [ - _lesson(date: DateTime(2026, 5, 6), startHhmm: 800, endHhmm: 845, subjectName: 'MA'), - _lesson(date: DateTime(2026, 5, 6), startHhmm: 900, endHhmm: 945, subjectName: 'EN'), - _lesson(date: DateTime(2026, 5, 6), startHhmm: 1000, endHhmm: 1045, subjectName: 'BIO'), + _lesson( + date: DateTime(2026, 5, 6), + startHhmm: 800, + endHhmm: 845, + subjectName: 'MA', + ), + _lesson( + date: DateTime(2026, 5, 6), + startHhmm: 900, + endHhmm: 945, + subjectName: 'EN', + ), + _lesson( + date: DateTime(2026, 5, 6), + startHhmm: 1000, + endHhmm: 1045, + subjectName: 'BIO', + ), ]; final events = GetCustomTimetableEventResponse([ _event( @@ -264,8 +290,18 @@ void main() { test('event + same-slot duplicate regular: kept lesson shows +2', () { final lessons = [ - _lesson(date: DateTime(2026, 5, 6), startHhmm: 900, endHhmm: 945, subjectName: 'EN'), - _lesson(date: DateTime(2026, 5, 6), startHhmm: 900, endHhmm: 945, subjectName: 'MA'), + _lesson( + date: DateTime(2026, 5, 6), + startHhmm: 900, + endHhmm: 945, + subjectName: 'EN', + ), + _lesson( + date: DateTime(2026, 5, 6), + startHhmm: 900, + endHhmm: 945, + subjectName: 'MA', + ), ]; final events = GetCustomTimetableEventResponse([ _event( @@ -315,7 +351,12 @@ void main() { test('two events covering the same regular lesson bump it twice', () { final lessons = [ - _lesson(date: DateTime(2026, 5, 6), startHhmm: 900, endHhmm: 945, subjectName: 'EN'), + _lesson( + date: DateTime(2026, 5, 6), + startHhmm: 900, + endHhmm: 945, + subjectName: 'EN', + ), ]; final events = GetCustomTimetableEventResponse([ _event( @@ -361,16 +402,198 @@ void main() { }); }); + group('teacher plan', () { + final now = DateTime(2026, 5, 6, 7); + final day = DateTime(2026, 5, 6); + + WidgetLesson single(McTimetableEntry lesson) => + WidgetDataMapper.buildDayData( + now: now, + lessons: [lesson], + subjects: null, + rooms: null, + holidays: null, + showClassInsteadOfTeacher: true, + ).lessons.single; + + test('shows the classes in place of the teacher', () { + final lesson = single( + _lesson( + date: day, + startHhmm: 755, + endHhmm: 840, + subjectName: 'MA', + teacherName: 'MUE', + teacherOrgname: 'KUN', + classNames: const ['10a', '10b'], + ), + ); + expect(lesson.teacher, '10a, 10b'); + expect(lesson.originalTeacher, isNull); + }); + + test('leaves the slot empty for entries without a class', () { + final lesson = single( + _lesson( + date: day, + startHhmm: 920, + endHhmm: 935, + room: 'Hof', + teacherName: 'MUE', + lessonType: 'BREAK_SUPERVISION', + ), + ); + expect(lesson.teacher, isNull); + }); + }); + + group('tile color', () { + final now = DateTime(2026, 5, 6, 7); + final day = DateTime(2026, 5, 6); + + WidgetLesson single( + McTimetableEntry lesson, { + TimetableGetSubjectsResponse? subjects, + }) => WidgetDataMapper.buildDayData( + now: now, + lessons: [lesson], + subjects: subjects, + rooms: null, + holidays: null, + ).lessons.single; + + test('regular lesson without subject color uses the status red', () { + final lesson = single( + _lesson( + date: day, + startHhmm: 755, + endHhmm: 840, + subjectName: 'MA', + teacherName: 'MUE', + ), + ); + expect(lesson.color, '#993333'); + }); + + test('regular lesson takes the personal subject color', () { + final lesson = single( + _lesson( + date: day, + startHhmm: 755, + endHhmm: 840, + subjectName: 'MA', + teacherName: 'MUE', + ), + subjects: TimetableGetSubjectsResponse( + result: [ + McSubject( + id: 1, + shortName: 'MA', + longName: 'Mathematik', + color: 'indigo', + ), + ], + ), + ); + expect(lesson.color, '#5C6BC0'); + }); + + test('substitution keeps its signal color despite a subject color', () { + final lesson = single( + _lesson( + date: day, + startHhmm: 755, + endHhmm: 840, + status: 'IRREGULAR', + subjectName: 'MA', + teacherName: 'MUE', + ), + subjects: TimetableGetSubjectsResponse( + result: [ + McSubject( + id: 1, + shortName: 'MA', + longName: 'Mathematik', + color: 'indigo', + ), + ], + ), + ); + expect(lesson.status, WidgetLessonStatus.irregular); + expect(lesson.color, '#8F19B3'); + }); + + test('supervision is classified as duty with room in the title', () { + final lesson = single( + _lesson( + date: day, + startHhmm: 920, + endHhmm: 935, + room: 'Hof', + teacherName: 'MUE', + lessonType: 'BREAK_SUPERVISION', + ), + ); + expect(lesson.status, WidgetLessonStatus.duty); + expect(lesson.subjectShort, 'Hof Aufsicht'); + expect(lesson.color, '#00796B'); + }); + + test('custom event resolves legacy color names', () { + final data = WidgetDataMapper.buildDayData( + now: now, + lessons: const [], + subjects: null, + rooms: null, + holidays: null, + customEvents: GetCustomTimetableEventResponse([ + _event( + id: 'e', + title: 'Probe', + start: DateTime(2026, 5, 6, 14), + end: DateTime(2026, 5, 6, 15), + ), + ]), + ); + expect(data.lessons.single.color, '#FFA000'); + }); + }); + group('buildWeekData', () { final now = DateTime(2026, 5, 5, 10); // Tuesday test('covers the 14-day window including next week', () { final lessons = [ - _lesson(date: DateTime(2026, 5, 4), startHhmm: 800, endHhmm: 845, subjectName: 'MO'), - _lesson(date: DateTime(2026, 5, 6), startHhmm: 800, endHhmm: 845, subjectName: 'WE'), - _lesson(date: DateTime(2026, 5, 8), startHhmm: 800, endHhmm: 845, subjectName: 'FR'), - _lesson(date: DateTime(2026, 5, 11), startHhmm: 800, endHhmm: 845, subjectName: 'NEXT'), - _lesson(date: DateTime(2026, 5, 18), startHhmm: 800, endHhmm: 845, subjectName: 'FAR'), + _lesson( + date: DateTime(2026, 5, 4), + startHhmm: 800, + endHhmm: 845, + subjectName: 'MO', + ), + _lesson( + date: DateTime(2026, 5, 6), + startHhmm: 800, + endHhmm: 845, + subjectName: 'WE', + ), + _lesson( + date: DateTime(2026, 5, 8), + startHhmm: 800, + endHhmm: 845, + subjectName: 'FR', + ), + _lesson( + date: DateTime(2026, 5, 11), + startHhmm: 800, + endHhmm: 845, + subjectName: 'NEXT', + ), + _lesson( + date: DateTime(2026, 5, 18), + startHhmm: 800, + endHhmm: 845, + subjectName: 'FAR', + ), ]; final data = WidgetDataMapper.buildWeekData( now: now, @@ -380,10 +603,12 @@ void main() { holidays: null, ); expect(data.anchorDate, DateTime(2026, 5, 4)); - expect( - data.lessons.map((l) => l.subjectShort).toList(), - ['MO', 'WE', 'FR', 'NEXT'], - ); + expect(data.lessons.map((l) => l.subjectShort).toList(), [ + 'MO', + 'WE', + 'FR', + 'NEXT', + ]); }); test('window stays on the current week when the anchor rolls forward', () { @@ -391,8 +616,18 @@ void main() { // still starts at the current week's Monday so today stays available. final fridayEvening = DateTime(2026, 5, 8, 18); final lessons = [ - _lesson(date: DateTime(2026, 5, 8), startHhmm: 800, endHhmm: 845, subjectName: 'FR'), - _lesson(date: DateTime(2026, 5, 11), startHhmm: 800, endHhmm: 845, subjectName: 'NEXT'), + _lesson( + date: DateTime(2026, 5, 8), + startHhmm: 800, + endHhmm: 845, + subjectName: 'FR', + ), + _lesson( + date: DateTime(2026, 5, 11), + startHhmm: 800, + endHhmm: 845, + subjectName: 'NEXT', + ), ]; final data = WidgetDataMapper.buildWeekData( now: fridayEvening, @@ -402,10 +637,7 @@ void main() { holidays: null, ); expect(data.anchorDate, DateTime(2026, 5, 11)); - expect( - data.lessons.map((l) => l.subjectShort).toList(), - ['FR', 'NEXT'], - ); + expect(data.lessons.map((l) => l.subjectShort).toList(), ['FR', 'NEXT']); }); test('carries per-day holiday info in days', () { @@ -429,8 +661,10 @@ void main() { expect(data.days, hasLength(WidgetDataMapper.weekWindowDays)); expect(data.days.first.date, DateTime(2026, 5, 4)); expect(data.days.last.date, DateTime(2026, 5, 17)); - final holidayDays = - data.days.where((d) => d.isHoliday).map((d) => d.date).toList(); + final holidayDays = data.days + .where((d) => d.isHoliday) + .map((d) => d.date) + .toList(); expect(holidayDays, [DateTime(2026, 5, 14), DateTime(2026, 5, 15)]); expect( data.days.firstWhere((d) => d.isHoliday).holidayName, From 1fd6264d1032b3e3fcd3161422c7d06012915b88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Sun, 27 Sep 2026 14:38:48 +0200 Subject: [PATCH 13/18] fixed notification taps being ignored when they cold-started the app --- lib/app.dart | 29 +++++------ lib/model/session_wipe.dart | 3 +- lib/notification/notification_controller.dart | 28 +++------- lib/notification/notification_tasks.dart | 15 ++++++ lib/push/push_tap_router.dart | 46 +++++++++------- lib/push/push_target.dart | 32 ++++++++++++ test/push/push_tap_router_test.dart | 52 +++++++++++++++++++ 7 files changed, 146 insertions(+), 59 deletions(-) create mode 100644 lib/push/push_target.dart create mode 100644 test/push/push_tap_router_test.dart diff --git a/lib/app.dart b/lib/app.dart index a8d38f1..37d1fae 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -13,6 +13,7 @@ import 'main.dart'; import 'model/account_data.dart'; import 'model/data_cleaner.dart'; import 'notification/notification_controller.dart'; +import 'notification/notification_service.dart'; import 'notification/notification_tasks.dart'; import 'push/push_registration.dart'; import 'push/push_tap_router.dart'; @@ -113,18 +114,11 @@ class _AppState extends State with WidgetsBindingObserver { } } - void _onPushTapPending() { - final token = PushTapRouter.pendingChatToken.value; - if (token == null || !mounted) return; - PushTapRouter.pendingChatToken.value = null; - NotificationTasks.navigateToTalk(context, chatToken: token); - } - - void _onNewsletterTapPending() { - final id = PushTapRouter.pendingNewsletterId.value; - if (id == null || !mounted) return; - PushTapRouter.pendingNewsletterId.value = null; - AppRoutes.openNewsletterById(context, id: id); + void _onPushTargetPending() { + final target = PushTapRouter.pendingTarget.value; + if (target == null || !mounted) return; + PushTapRouter.pendingTarget.value = null; + NotificationTasks.openPushTarget(context, target); } Future _handlePendingWidgetNavigation() async { @@ -237,8 +231,12 @@ class _AppState extends State with WidgetsBindingObserver { // Android renders pushes locally, so a tap arrives via the local // notifications callback (PushTapRouter) rather than onMessageOpenedApp. - PushTapRouter.pendingChatToken.addListener(_onPushTapPending); - PushTapRouter.pendingNewsletterId.addListener(_onNewsletterTapPending); + PushTapRouter.pendingTarget.addListener(_onPushTargetPending); + unawaited( + PushTapRouter.handleAppLaunch( + NotificationService().flutterLocalNotificationsPlugin, + ), + ); _onMessageSub = FirebaseMessaging.onMessage.listen((message) { if (!mounted) return; @@ -271,8 +269,7 @@ class _AppState extends State with WidgetsBindingObserver { _onMessageSub?.cancel(); _onMessageOpenedAppSub?.cancel(); _fcmTokenRefreshSub?.cancel(); - PushTapRouter.pendingChatToken.removeListener(_onPushTapPending); - PushTapRouter.pendingNewsletterId.removeListener(_onNewsletterTapPending); + PushTapRouter.pendingTarget.removeListener(_onPushTargetPending); ShareIntentListener.pending.removeListener(_handlePendingShare); Main.bottomNavigator.removeListener(_onTabControllerChanged); WidgetsBinding.instance.removeObserver(this); diff --git a/lib/model/session_wipe.dart b/lib/model/session_wipe.dart index 644f97a..5efa03e 100644 --- a/lib/model/session_wipe.dart +++ b/lib/model/session_wipe.dart @@ -46,8 +46,7 @@ abstract final class SessionWipe { _step('share folder cache', resetTalkShareFolderCache); _step('pending navigation', () { AppRoutes.pendingChatToken.value = null; - PushTapRouter.pendingChatToken.value = null; - PushTapRouter.pendingNewsletterId.value = null; + PushTapRouter.pendingTarget.value = null; }); _step('file clipboard', FileClipboard.instance.clear); _step('downloads', DownloadManager.instance.clearAll); diff --git a/lib/notification/notification_controller.dart b/lib/notification/notification_controller.dart index 817ce2f..dd35a00 100644 --- a/lib/notification/notification_controller.dart +++ b/lib/notification/notification_controller.dart @@ -5,7 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../push/push_message_handler.dart'; -import '../routing/app_routes.dart'; +import '../push/push_target.dart'; import '../state/app/modules/chat/bloc/chat_bloc.dart'; import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import '../utils/debouncer.dart'; @@ -51,18 +51,15 @@ class NotificationController { RemoteMessage message, BuildContext context, ) async { - final newsletterId = _extractNewsletterId(message); - if (newsletterId != null) { - AppRoutes.openNewsletterById( + final target = resolvePushTarget(message.data); + if (target != null) { + NotificationTasks.openPushTarget( context, - id: newsletterId, + target, title: message.notification?.title, ); } else { - NotificationTasks.navigateToTalk( - context, - chatToken: _extractChatToken(message), - ); + NotificationTasks.navigateToTalk(context); } NotificationTasks.updateProviders(context); unawaited(NotificationTasks.refreshBadge()); @@ -77,17 +74,4 @@ class NotificationController { ); }); } - - static String? _extractChatToken(RemoteMessage message) { - for (final key in const ['chatToken', 'token', 'roomToken']) { - final value = message.data[key]; - if (value is String && value.isNotEmpty) return value; - } - return null; - } - - static String? _extractNewsletterId(RemoteMessage message) { - final value = message.data['newsletterId']; - return value is String && value.isNotEmpty ? value : null; - } } diff --git a/lib/notification/notification_tasks.dart b/lib/notification/notification_tasks.dart index e3eb449..57d2913 100644 --- a/lib/notification/notification_tasks.dart +++ b/lib/notification/notification_tasks.dart @@ -7,6 +7,7 @@ import 'package:flutter_app_badge/flutter_app_badge.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../push/chat_thread_store.dart'; +import '../push/push_target.dart'; import '../routing/app_routes.dart'; import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import 'notification_service.dart'; @@ -72,6 +73,20 @@ class NotificationTasks { context.read().refresh(); } + /// [title] is the notification title, when the tap came with one. + static void openPushTarget( + BuildContext context, + PushTarget target, { + String? title, + }) { + switch (target) { + case NewsletterTarget(:final newsletterId): + AppRoutes.openNewsletterById(context, id: newsletterId, title: title); + case ChatTarget(:final chatToken): + navigateToTalk(context, chatToken: chatToken); + } + } + /// Switches to the Talk tab. If [chatToken] is provided, also schedules /// the matching chat to be opened automatically once the chat list view /// resolves the token (handled inside [ChatList]). diff --git a/lib/push/push_tap_router.dart b/lib/push/push_tap_router.dart index b2ff32e..f77102a 100644 --- a/lib/push/push_tap_router.dart +++ b/lib/push/push_tap_router.dart @@ -1,24 +1,22 @@ import 'dart:convert'; +import 'dart:developer'; import 'package:flutter/foundation.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'push_actions.dart'; +import 'push_target.dart'; /// Routes foreground notification interactions from the single /// flutter_local_notifications response callback. Action responses (reply / /// mark-read) are dispatched straight to [PushActions]; a plain tap publishes -/// the target chat token via [pendingChatToken] for [App] to navigate to. +/// its target via [pendingTarget] for [App] to navigate to. class PushTapRouter { PushTapRouter._(); - /// Chat token of the most recently tapped Talk notification, or null. [App] - /// listens to this and opens the chat, then resets it to null. - static final ValueNotifier pendingChatToken = ValueNotifier(null); - - /// Newsletter id of the most recently tapped Marianum-Message notification, - /// or null. [App] listens to this and opens the message, then resets it. - static final ValueNotifier pendingNewsletterId = ValueNotifier(null); + /// Target of the most recently tapped notification, or null. [App] listens + /// to this and navigates, then resets it to null. + static final ValueNotifier pendingTarget = ValueNotifier(null); static void handleResponse(NotificationResponse response) { final actionId = response.actionId; @@ -29,13 +27,28 @@ class PushTapRouter { } final map = _payloadMap(response.payload); if (map == null) return; - final newsletterId = _stringValue(map, 'newsletterId'); - if (newsletterId != null) { - pendingNewsletterId.value = newsletterId; - return; + final target = resolvePushTarget(map); + if (target != null) pendingTarget.value = target; + } + + static bool _launchHandled = false; + + /// Routes the tap that cold-started the app. The plugin reports such a tap + /// only through its launch details, never through the response callback; + /// the details stay set for the whole process, hence the one-shot guard. + static Future handleAppLaunch( + FlutterLocalNotificationsPlugin plugin, + ) async { + if (_launchHandled) return; + _launchHandled = true; + try { + final details = await plugin.getNotificationAppLaunchDetails(); + final response = details?.notificationResponse; + if (details?.didNotificationLaunchApp != true || response == null) return; + handleResponse(response); + } on Object catch (e) { + log('Reading the notification launch details failed: $e'); } - final token = _stringValue(map, 'chatToken'); - if (token != null) pendingChatToken.value = token; } static Map? _payloadMap(String? payload) { @@ -46,9 +59,4 @@ class PushTapRouter { return null; } } - - static String? _stringValue(Map map, String key) { - final value = map[key]; - return value is String && value.isNotEmpty ? value : null; - } } diff --git a/lib/push/push_target.dart b/lib/push/push_target.dart new file mode 100644 index 0000000..11b24e0 --- /dev/null +++ b/lib/push/push_target.dart @@ -0,0 +1,32 @@ +/// Where a tapped notification leads. +sealed class PushTarget { + const PushTarget(); +} + +class NewsletterTarget extends PushTarget { + final String newsletterId; + const NewsletterTarget(this.newsletterId); +} + +class ChatTarget extends PushTarget { + final String chatToken; + const ChatTarget(this.chatToken); +} + +/// Resolves the data of a tapped notification — the payload of a locally +/// rendered one as well as the data of an FCM message. Null when it names no +/// known target. +PushTarget? resolvePushTarget(Map data) { + String? value(String key) { + final value = data[key]; + return value is String && value.isNotEmpty ? value : null; + } + + if (value('newsletterId') case final newsletterId?) { + return NewsletterTarget(newsletterId); + } + for (final key in const ['chatToken', 'token', 'roomToken']) { + if (value(key) case final chatToken?) return ChatTarget(chatToken); + } + return null; +} diff --git a/test/push/push_tap_router_test.dart b/test/push/push_tap_router_test.dart new file mode 100644 index 0000000..27fc8c7 --- /dev/null +++ b/test/push/push_tap_router_test.dart @@ -0,0 +1,52 @@ +import 'dart:convert'; + +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/push/push_tap_router.dart'; +import 'package:marianum_mobile/push/push_target.dart'; + +void main() { + group('resolvePushTarget', () { + test('newsletter pushes route by newsletterId', () { + expect( + resolvePushTarget({'source': 'connect', 'newsletterId': 'n1'}), + isA().having((t) => t.newsletterId, 'id', 'n1'), + ); + }); + + test('chat pushes accept every token key in use', () { + for (final key in ['chatToken', 'token', 'roomToken']) { + expect( + resolvePushTarget({key: 'abc', 'nid': 5}), + isA().having((t) => t.chatToken, 'chatToken', 'abc'), + reason: key, + ); + } + }); + + test('empty ids and unrelated data name no target', () { + expect(resolvePushTarget({'newsletterId': ''}), isNull); + expect(resolvePushTarget({'type': 'widget-refresh'}), isNull); + expect(resolvePushTarget({'chatToken': 7}), isNull); + }); + }); + + group('PushTapRouter.handleResponse', () { + setUp(() => PushTapRouter.pendingTarget.value = null); + + NotificationResponse tap(String payload) => NotificationResponse( + notificationResponseType: NotificationResponseType.selectedNotification, + payload: payload, + ); + + test('publishes the target of a tapped notification', () { + PushTapRouter.handleResponse(tap(jsonEncode({'newsletterId': 'n1'}))); + expect(PushTapRouter.pendingTarget.value, isA()); + }); + + test('ignores broken payloads', () { + PushTapRouter.handleResponse(tap('not json')); + expect(PushTapRouter.pendingTarget.value, isNull); + }); + }); +} From 79b33ab248a36b76f9ea6a3b13cec7a72564c4f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Sun, 27 Sep 2026 14:40:04 +0200 Subject: [PATCH 14/18] reworked login screen texts and extracted shared login form parts --- lib/view/login/login.dart | 3 +- lib/view/login/widgets/login_branding.dart | 20 +-- lib/view/login/widgets/login_card.dart | 163 ++++++------------- lib/view/login/widgets/login_form_parts.dart | 109 +++++++++++++ 4 files changed, 163 insertions(+), 132 deletions(-) create mode 100644 lib/view/login/widgets/login_form_parts.dart diff --git a/lib/view/login/login.dart b/lib/view/login/login.dart index d34a9f4..8b0262e 100644 --- a/lib/view/login/login.dart +++ b/lib/view/login/login.dart @@ -93,8 +93,7 @@ class _LoginState extends State with SingleTickerProviderStateMixin { controller: _controller, onSuccess: _onLoginSuccess, ), - const SizedBox(height: 18), - const LoginDisclaimer(), + const SizedBox(height: 12), ], ), const Column( diff --git a/lib/view/login/widgets/login_branding.dart b/lib/view/login/widgets/login_branding.dart index d25167c..9d6bf2a 100644 --- a/lib/view/login/widgets/login_branding.dart +++ b/lib/view/login/widgets/login_branding.dart @@ -29,7 +29,7 @@ class LoginHeader extends StatelessWidget { ), const SizedBox(height: 6), Text( - 'Stundenplan, Talk & Dateien an einem Ort.', + 'Stundenplan, Talk, Dateien und mehr - alles an einem Ort für deinen Schulalltag am Marianum Fulda.', textAlign: TextAlign.center, style: TextStyle( color: Colors.white.withValues(alpha: 0.85), @@ -41,24 +41,6 @@ class LoginHeader extends StatelessWidget { ); } -class LoginDisclaimer extends StatelessWidget { - const LoginDisclaimer({super.key}); - - @override - Widget build(BuildContext context) => Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Text( - 'Alles für deinen Schulalltag am Marianum Fulda.', - textAlign: TextAlign.center, - style: TextStyle( - color: Colors.white.withValues(alpha: 0.75), - fontSize: 11, - height: 1.4, - ), - ), - ); -} - class LoginFooter extends StatelessWidget { const LoginFooter({super.key}); diff --git a/lib/view/login/widgets/login_card.dart b/lib/view/login/widgets/login_card.dart index a259d98..a38c152 100644 --- a/lib/view/login/widgets/login_card.dart +++ b/lib/view/login/widgets/login_card.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../../../routing/app_routes.dart'; import '../login_controller.dart'; import 'login_error_banner.dart'; +import 'login_form_parts.dart'; /// White Card hosting the login form (heading, two text fields, error /// banner, submit button). Submitting calls [controller.submit] and signals @@ -75,122 +76,62 @@ class _LoginCardState extends State { } } - InputDecoration _decoration(ThemeData theme, String label, IconData icon) => - InputDecoration( - labelText: label, - prefixIcon: Icon(icon), - filled: true, - fillColor: theme.colorScheme.surfaceContainerHighest.withValues( - alpha: 0.4, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: theme.colorScheme.primary, width: 1.5), - ), - ); - @override Widget build(BuildContext context) { final theme = Theme.of(context); final loading = widget.controller.loading; - return Card( - elevation: 8, - shadowColor: Colors.black.withValues(alpha: 0.35), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - color: theme.colorScheme.surface, - child: Padding( - padding: const EdgeInsets.fromLTRB(24, 24, 24, 20), - child: Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - 'Anmelden', - style: theme.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 6), - Text( - 'Melde dich mit deinen Marianum-Zugangsdaten an.', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 20), - TextFormField( - key: const Key('login-username-field'), - controller: _usernameController, - enabled: !loading, - validator: _required, - autocorrect: false, - textInputAction: TextInputAction.next, - onFieldSubmitted: (_) => _passwordFocus.requestFocus(), - decoration: _decoration( - theme, - 'Nutzername', - Icons.person_outline, - ), - ), - const SizedBox(height: 12), - TextFormField( - key: const Key('login-password-field'), - controller: _passwordController, - focusNode: _passwordFocus, - enabled: !loading, - validator: _required, - obscureText: true, - obscuringCharacter: '•', - autocorrect: false, - enableSuggestions: false, - keyboardType: TextInputType.visiblePassword, - textInputAction: TextInputAction.done, - onFieldSubmitted: (_) => _submit(), - decoration: _decoration(theme, 'Passwort', Icons.lock_outline), - ), - LoginErrorBanner( - message: widget.controller.errorMessage, - details: widget.controller.errorDetails, - ), - const SizedBox(height: 20), - SizedBox( - height: 50, - child: FilledButton( - key: const Key('login-submit-button'), - onPressed: loading ? null : _submit, - style: FilledButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - textStyle: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, - ), - ), - child: loading - ? const SizedBox( - height: 22, - width: 22, - child: CircularProgressIndicator( - strokeWidth: 2.5, - color: Colors.white, - ), - ) - : const Text('Anmelden'), - ), - ), - ], + return Form( + key: _formKey, + child: LoginCardFrame( + title: 'Anmelden', + hint: 'Melde dich mit deinen Marianum-Zugangsdaten an.', + children: [ + TextFormField( + key: const Key('login-username-field'), + controller: _usernameController, + enabled: !loading, + validator: _required, + autocorrect: false, + textInputAction: TextInputAction.next, + onFieldSubmitted: (_) => _passwordFocus.requestFocus(), + decoration: loginInputDecoration( + theme, + 'Nutzername', + Icons.person_outline, + ), ), - ), + const SizedBox(height: 12), + TextFormField( + key: const Key('login-password-field'), + controller: _passwordController, + focusNode: _passwordFocus, + enabled: !loading, + validator: _required, + obscureText: true, + obscuringCharacter: '•', + autocorrect: false, + enableSuggestions: false, + keyboardType: TextInputType.visiblePassword, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _submit(), + decoration: loginInputDecoration( + theme, + 'Passwort', + Icons.lock_outline, + ), + ), + LoginErrorBanner( + message: widget.controller.errorMessage, + details: widget.controller.errorDetails, + ), + const SizedBox(height: 20), + LoginSubmitButton( + key: const Key('login-submit-button'), + label: 'Anmelden', + loading: loading, + onPressed: _submit, + ), + ], ), ); } diff --git a/lib/view/login/widgets/login_form_parts.dart b/lib/view/login/widgets/login_form_parts.dart new file mode 100644 index 0000000..6f6d6b2 --- /dev/null +++ b/lib/view/login/widgets/login_form_parts.dart @@ -0,0 +1,109 @@ +import 'package:flutter/material.dart'; + +/// Filled, borderless text field look shared by the login cards. +InputDecoration loginInputDecoration( + ThemeData theme, + String label, + IconData icon, +) => InputDecoration( + labelText: label, + prefixIcon: Icon(icon), + filled: true, + fillColor: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.4), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: theme.colorScheme.primary, width: 1.5), + ), +); + +/// Card frame with heading and hint line shared by the login cards. +class LoginCardFrame extends StatelessWidget { + final String title; + final String hint; + final List children; + + const LoginCardFrame({ + required this.title, + required this.hint, + required this.children, + super.key, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Card( + elevation: 8, + shadowColor: Colors.black.withValues(alpha: 0.35), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + color: theme.colorScheme.surface, + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + title, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 6), + Text( + hint, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 20), + ...children, + ], + ), + ), + ); + } +} + +/// Full-width primary button that swaps its label for a spinner while busy. +class LoginSubmitButton extends StatelessWidget { + final String label; + final bool loading; + final VoidCallback onPressed; + + const LoginSubmitButton({ + required this.label, + required this.loading, + required this.onPressed, + super.key, + }); + + @override + Widget build(BuildContext context) => SizedBox( + height: 50, + child: FilledButton( + onPressed: loading ? null : onPressed, + style: FilledButton.styleFrom( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + textStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600), + ), + child: loading + ? const SizedBox( + height: 22, + width: 22, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: Colors.white, + ), + ) + : Text(label), + ), + ); +} From 5e65c4671aeddf52f40c837bcb60274b03a5f81d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Sun, 27 Sep 2026 14:49:38 +0200 Subject: [PATCH 15/18] replaced account data singleton with a session model --- CLAUDE.md | 3 + ios/PUSH_NSE_SETUP.md | 4 +- lib/api/demo/data/demo_talk.dart | 7 +- lib/api/demo/demo_mode.dart | 6 +- lib/api/errors/error_mapper.dart | 9 +- .../app_password/get_app_password.dart | 8 +- .../cloud_users/cloud_users_actions.dart | 17 +- lib/api/marianumcloud/nextcloud_ocs.dart | 4 +- lib/api/marianumcloud/webdav/webdav_api.dart | 16 +- .../auth/auth_interceptor.dart | 18 +- .../auth/session_validator.dart | 44 +- .../marianumconnect/auth/token_storage.dart | 17 +- .../marianumconnect_query.dart | 19 +- .../queries/auth_verify/auth_verify.dart | 9 +- .../get_newsletter_file.dart | 11 +- .../get_ticker_page_file.dart | 11 +- .../telemetry_device_id.dart | 12 +- .../custom_events_migration.dart | 20 +- lib/api/request_cache.dart | 6 +- lib/app.dart | 6 +- lib/background/widget_background_task.dart | 28 +- lib/main.dart | 26 +- lib/model/account_data.dart | 398 ------------------ lib/push/push_actions.dart | 20 +- lib/push/push_device_info.dart | 15 + lib/push/push_message_handler.dart | 4 +- lib/push/push_registration.dart | 67 +-- lib/push/push_registration_store.dart | 2 +- lib/push/push_secure_storage.dart | 2 +- lib/push/push_status.dart | 7 +- lib/routing/app_routes.dart | 12 +- lib/session/nextcloud_credentials.dart | 77 ++++ lib/session/session.dart | 64 +++ lib/session/session_codec.dart | 54 +++ lib/session/session_lifecycle.dart | 29 ++ lib/session/session_manager.dart | 288 +++++++++++++ .../loadable_hydrated_bloc.dart | 6 +- .../capabilities/bloc/capabilities_cubit.dart | 6 +- .../bloc/nextcloud_capabilities_cubit.dart | 7 +- lib/utils/downloads/download_manager.dart | 4 +- lib/utils/random_id.dart | 19 + lib/utils/session_single_flight.dart | 4 +- lib/view/login/account_loading_screen.dart | 4 +- lib/view/login/login.dart | 17 + lib/view/login/login_controller.dart | 46 +- lib/view/login/nextcloud_login_flow_page.dart | 10 +- .../pages/files/widgets/file_leading.dart | 4 +- .../element_picker_page.dart | 4 +- lib/view/pages/settings/diagnostics_page.dart | 24 +- .../settings/sections/account_section.dart | 24 +- lib/view/pages/talk/data/chat_message.dart | 4 +- .../pages/talk/details/message_reactions.dart | 8 +- .../talk/details/participants_list_view.dart | 4 +- .../pages/talk/details/shared_items_view.dart | 20 +- lib/view/pages/talk/widgets/chat_tile.dart | 10 +- .../pages/talk/widgets/poll_options_list.dart | 4 +- .../file_viewer/unknown_preview_block.dart | 4 +- lib/widget/user_avatar.dart | 7 +- lib/widget_data/widget_publisher.dart | 8 +- test/session/session_codec_test.dart | 110 +++++ 60 files changed, 1019 insertions(+), 679 deletions(-) delete mode 100644 lib/model/account_data.dart create mode 100644 lib/push/push_device_info.dart create mode 100644 lib/session/nextcloud_credentials.dart create mode 100644 lib/session/session.dart create mode 100644 lib/session/session_codec.dart create mode 100644 lib/session/session_lifecycle.dart create mode 100644 lib/session/session_manager.dart create mode 100644 lib/utils/random_id.dart create mode 100644 test/session/session_codec_test.dart diff --git a/CLAUDE.md b/CLAUDE.md index 5202b16..1777378 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,7 @@ Flutter-App für die Schul-Community: Webuntis-Stundenplan, Nextcloud Talk + Fil ``` lib/ ├── api/ HTTP-Layer pro Backend (mhsl/, marianumcloud/, webuntis/, holidays/) +├── session/ Session-Modell, SessionManager, SessionLifecycle ├── state/app/modules/ BLoC pro Feature-Modul (timetable, chat, chat_list, files, ...) ├── state/app/infrastructure LoadableState, DataLoader, geteilte BLoC-Bausteine ├── view/ Screens @@ -51,6 +52,8 @@ lib/ **Settings:** Pro Feature ein Freezed-Modell unter `lib/storage/`, persistiert via HydratedBloc. +**Session:** Die aktive Sitzung liegt in `SessionManager().current` (`lib/session/`). Nextcloud-Zugriffe nur über `SessionManager().requireNextcloud()`. Abmelden ausschließlich über `SessionLifecycle.signOut()`. Die Keychain-Keys in `SessionKeys` sind eingefroren (Bestandsinstallationen, iOS-NSE). + ## Build / Run ```bash diff --git a/ios/PUSH_NSE_SETUP.md b/ios/PUSH_NSE_SETUP.md index 6bddbd4..b9b4d7d 100644 --- a/ios/PUSH_NSE_SETUP.md +++ b/ios/PUSH_NSE_SETUP.md @@ -180,11 +180,11 @@ AppDelegate) exakt so ab: `GenericPassword` + `kSecAttrAccount = ` + | `push_server_public_key_pem` | **Server**-Public-Key (per User), SPKI-PEM | `push_registration_store.dart` | **NSE** (Signatur prüfen) | | `push_device_identifier` | NC Device-Identifier | `push_registration_store.dart` | Dart | | `push_registered_fcm_token` | FCM-Token der Registrierung | `push_registration_store.dart` | Dart | -| `nextcloud_app_password` | NC App-Password | `account_data.dart` | **AppDelegate** (Basic-Auth) | +| `nextcloud_app_password` | NC App-Password | `session_manager.dart` | **AppDelegate** (Basic-Auth) | | `nextcloud_username` | NC Username | `push_registration_store.dart` (**neu**) | **AppDelegate** (Basic-Auth) | | `nextcloud_base_url` | z.B. `https://cloud.marianum-fulda.de` | `push_registration_store.dart` (**neu**) | **AppDelegate** (OCS-URL) | -> `username` und `password` (Realpasswort) liegen in AccountDatas **Default**-Storage +> `username` und `password` (Realpasswort) liegen im **Default**-Storage des SessionManagers > **ohne** `groupId` → nur im primären Access-Group der App, **für die NSE > unsichtbar**. Deshalb werden `nextcloud_username` + `nextcloud_base_url` bei > jeder `register()`-Runde zusätzlich **group-scoped** geschrieben. Das diff --git a/lib/api/demo/data/demo_talk.dart b/lib/api/demo/data/demo_talk.dart index eb9d317..bff897e 100644 --- a/lib/api/demo/data/demo_talk.dart +++ b/lib/api/demo/data/demo_talk.dart @@ -1,4 +1,4 @@ -import '../../../model/account_data.dart'; +import '../../../session/session_manager.dart'; import '../../marianumcloud/talk/chat/get_chat_response.dart'; import '../../marianumcloud/talk/room/get_room_response.dart'; import '../demo_persona.dart'; @@ -204,7 +204,8 @@ class DemoTalk { id: base + 2, token: token, ago: const Duration(days: 1, hours: 6), - message: 'Danke! Können wir Aufgabe 5 nächste Stunde nochmal besprechen?', + message: + 'Danke! Können wir Aufgabe 5 nächste Stunde nochmal besprechen?', ), _msg( id: base + 3, @@ -366,7 +367,7 @@ class DemoTalk { }) => _msg( id: id, token: token, - actor: AccountData().getUsername(), + actor: SessionManager().requireNextcloud().username, display: DemoPersona.studentName, ago: ago, message: message, diff --git a/lib/api/demo/demo_mode.dart b/lib/api/demo/demo_mode.dart index 4da95d8..a0ebd8f 100644 --- a/lib/api/demo/demo_mode.dart +++ b/lib/api/demo/demo_mode.dart @@ -1,4 +1,4 @@ -import '../../model/account_data.dart'; +import '../../session/session_manager.dart'; /// Central switch for the client-side demo mode. /// @@ -8,7 +8,7 @@ import '../../model/account_data.dart'; /// Play reviewers and automated screenshot runs see a fully populated app /// without a real account and without any network dependency. /// -/// The flag is persisted through [AccountData.isDemo], so a demo session +/// The flag is persisted through [Session.isDemo], so a demo session /// survives cold starts exactly like a normal login. It works in release builds /// too — reviewers run the shipped release build. class DemoMode { @@ -23,5 +23,5 @@ class DemoMode { username.trim().toLowerCase().startsWith(usernamePrefix); /// True while the active session is a demo session. - static bool get active => AccountData().isDemo; + static bool get active => SessionManager().isDemo; } diff --git a/lib/api/errors/error_mapper.dart b/lib/api/errors/error_mapper.dart index 9cafa3a..0722b1a 100644 --- a/lib/api/errors/error_mapper.dart +++ b/lib/api/errors/error_mapper.dart @@ -5,6 +5,7 @@ import 'package:dio/dio.dart'; import 'package:http/http.dart' as http; import 'package:nextcloud/nextcloud.dart'; +import '../../session/session.dart'; import '../api_error.dart'; import '../http_errors.dart'; import '../marianumcloud/talk/talk_error.dart'; @@ -61,7 +62,9 @@ AppException? _dioToAppException(DioException error) { AppException _dynamiteToAppException(DynamiteApiException error) { final status = error.statusCode; final preview = previewBody(error.body); - final detail = preview.isEmpty ? 'HTTP $status' : 'HTTP $status body=$preview'; + final detail = preview.isEmpty + ? 'HTTP $status' + : 'HTTP $status body=$preview'; switch (status) { case 401: return AuthException.unauthorized(technicalDetails: detail); @@ -86,6 +89,9 @@ String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) { if (error is AppException) return error.userMessage; if (error is TalkError) return TalkException(error).userMessage; + if (error is NextcloudUnavailableException) { + return 'Diese Funktion ist mit deinem Konto nicht verfügbar.'; + } if (error is DioException) { final mapped = _dioToAppException(error); @@ -136,6 +142,7 @@ String? errorToTechnicalDetails(Object? error) { bool errorAllowsRetry(Object? error) { if (error == null) return true; if (error is AppException) return error.allowRetry; + if (error is NextcloudUnavailableException) return false; if (error is DioException) { final mapped = _dioToAppException(error); if (mapped != null) return mapped.allowRetry; diff --git a/lib/api/marianumcloud/app_password/get_app_password.dart b/lib/api/marianumcloud/app_password/get_app_password.dart index 30c920c..eaa4005 100644 --- a/lib/api/marianumcloud/app_password/get_app_password.dart +++ b/lib/api/marianumcloud/app_password/get_app_password.dart @@ -2,13 +2,13 @@ import 'dart:convert'; import 'package:http/http.dart' as http; -import '../../../model/account_data.dart'; +import '../../../session/session_manager.dart'; import '../../http_errors.dart'; import '../nextcloud_ocs.dart'; /// Exchanges the user's real Nextcloud password for a scoped app password via /// `GET /ocs/v2.php/core/getapppassword`. All subsequent Nextcloud calls then -/// authenticate with the app password (see [AccountData.getBasicAuthHeader]), +/// authenticate with the app password (see [NextcloudCredentials.basicAuthHeader]), /// which is what the push-v2 registration binds to. /// /// Must authenticate with the *real* password — an app password cannot mint @@ -33,7 +33,9 @@ class GetAppPassword { // Deliberately NOT the shared Authorization value: that one prefers // the app password, but an app password cannot mint another one — // this endpoint requires the real password. - 'Authorization': AccountData().getRealPasswordBasicAuthHeader(), + 'Authorization': SessionManager() + .requireNextcloud() + .realPasswordBasicAuthHeader, }, ), ))!; diff --git a/lib/api/marianumcloud/cloud_users/cloud_users_actions.dart b/lib/api/marianumcloud/cloud_users/cloud_users_actions.dart index abd85c4..847b6f3 100644 --- a/lib/api/marianumcloud/cloud_users/cloud_users_actions.dart +++ b/lib/api/marianumcloud/cloud_users/cloud_users_actions.dart @@ -4,8 +4,8 @@ import 'dart:typed_data'; import 'package:http/http.dart' as http; -import '../../../model/account_data.dart'; import '../../../model/endpoint_data.dart'; +import '../../../session/session_manager.dart'; import '../../errors/parse_exception.dart'; import '../../http_errors.dart'; import '../nextcloud_ocs.dart'; @@ -27,12 +27,12 @@ Uri _coreAvatarUri() { return Uri.https(endpoint.domain, '${endpoint.path}/avatar/'); } -Uri _userInfoUri() => - NextcloudOcs.uri('cloud/users/${AccountData().getUsername()}'); +Uri _userInfoUri() => NextcloudOcs.uri( + 'cloud/users/${SessionManager().requireNextcloud().username}', +); Future _send( - Future Function(Uri uri, Map headers) - perform, + Future Function(Uri uri, Map headers) perform, Uri uri, ) async { final headers = NextcloudOcs.headers(); @@ -98,16 +98,13 @@ class GetUserInfo { try { final root = jsonDecode(response.body) as Map; final data = - (root['ocs'] as Map)['data'] - as Map; + (root['ocs'] as Map)['data'] as Map; return CloudUserInfo( userId: data['id'] as String, displayName: (data['displayname'] as String?) ?? '', ); } catch (e) { - throw ParseException( - technicalDetails: 'Cloud $uri user info parse: $e', - ); + throw ParseException(technicalDetails: 'Cloud $uri user info parse: $e'); } } } diff --git a/lib/api/marianumcloud/nextcloud_ocs.dart b/lib/api/marianumcloud/nextcloud_ocs.dart index c48121f..b127465 100644 --- a/lib/api/marianumcloud/nextcloud_ocs.dart +++ b/lib/api/marianumcloud/nextcloud_ocs.dart @@ -1,7 +1,7 @@ import 'dart:convert'; -import '../../model/account_data.dart'; import '../../model/endpoint_data.dart'; +import '../../session/session_manager.dart'; /// Shared headers and URI builder for Nextcloud OCS v2 endpoints. Used by /// TalkApi, AutocompleteApi, FileSharingApi. @@ -16,7 +16,7 @@ class NextcloudOcs { static Map headers() => { 'Accept': 'application/json', 'OCS-APIRequest': 'true', - 'Authorization': AccountData().getBasicAuthHeader(), + 'Authorization': SessionManager().requireNextcloud().basicAuthHeader, }; static Uri uri(String pathSuffix, {Map? queryParameters}) { diff --git a/lib/api/marianumcloud/webdav/webdav_api.dart b/lib/api/marianumcloud/webdav/webdav_api.dart index 5395706..34fe90b 100644 --- a/lib/api/marianumcloud/webdav/webdav_api.dart +++ b/lib/api/marianumcloud/webdav/webdav_api.dart @@ -1,7 +1,7 @@ import 'package:nextcloud/nextcloud.dart'; -import '../../../model/account_data.dart'; import '../../../model/endpoint_data.dart'; +import '../../../session/session_manager.dart'; import '../../api_response.dart'; abstract class WebdavApi { @@ -20,8 +20,8 @@ abstract class WebdavApi { static Future get webdav { // Keyed by user too: two accounts may share a password (no app password // minted), and the client would keep the previous login name. - final secret = - '${AccountData().getUsername()}:${AccountData().getNextcloudSecret()}'; + final nextcloud = SessionManager().requireNextcloud(); + final secret = '${nextcloud.username}:${nextcloud.secret}'; if (_webdav == null || _webdavSecret != secret) { _webdavSecret = secret; _webdav = establishWebdavConnection(); @@ -33,13 +33,13 @@ abstract class WebdavApi { NextcloudClient( Uri.parse('https://${EndpointData().nextcloud().full()}'), // App password preferred — with 2FA the real password is not accepted - // by Nextcloud at all (see AccountData.usesLoginFlow). - password: AccountData().getNextcloudSecret(), - loginName: AccountData().getUsername(), + // by Nextcloud at all (see NextcloudCredentials.usesLoginFlow). + password: SessionManager().requireNextcloud().secret, + loginName: SessionManager().requireNextcloud().username, ).webdav; /// Builds the WebDAV download URL without embedded credentials. Callers must - /// authenticate via the [AccountData.authHeaders] header instead. + /// authenticate via the [NextcloudCredentials.authHeaders] header instead. static String buildWebdavUrl() => - 'https://${EndpointData().nextcloud().full()}/remote.php/dav/files/${AccountData().getUsername()}/'; + 'https://${EndpointData().nextcloud().full()}/remote.php/dav/files/${SessionManager().requireNextcloud().username}/'; } diff --git a/lib/api/marianumconnect/auth/auth_interceptor.dart b/lib/api/marianumconnect/auth/auth_interceptor.dart index 7dc4e2a..3118911 100644 --- a/lib/api/marianumconnect/auth/auth_interceptor.dart +++ b/lib/api/marianumconnect/auth/auth_interceptor.dart @@ -1,12 +1,13 @@ import 'package:dio/dio.dart'; -import '../../../model/account_data.dart'; +import '../../../session/session.dart'; +import '../../../session/session_manager.dart'; import '../queries/auth_login/auth_login.dart'; import 'device_token_name.dart'; import 'token_storage.dart'; /// Adds the bearer token to outgoing Marianum-Connect requests and, on 401, -/// re-logs in once with the credentials in [AccountData] before retrying. +/// renews the token once before retrying. class MarianumConnectAuthInterceptor extends Interceptor { static const _retriedKey = 'mc_auth_retried'; @@ -87,25 +88,26 @@ class MarianumConnectAuthInterceptor extends Interceptor { } Future _performReLogin() async { - if (!AccountData().isPopulated()) return false; - final username = AccountData().getUsername(); + final session = SessionManager().current; + if (session is! CredentialSession) return false; + final username = session.username; // A background engine (widget task) keeps the account it loaded. When // the app signed that account out, its revoked token answers 401 — a // re-login would mint a fresh token for it into the shared keystore. - if (await AccountData().readStoredUsername() != username) return false; + if (await SessionManager().readStoredUsername() != username) return false; try { await _loginClient.run( username: username, - password: AccountData().getPassword(), + password: session.password, tokenName: await DeviceTokenName.resolve(), ); } catch (_) { - if (await AccountData().readStoredUsername() == username) { + if (await SessionManager().readStoredUsername() == username) { await _tokenStorage.clear(); } return false; } - final stored = await AccountData().readStoredUsername(); + final stored = await SessionManager().readStoredUsername(); if (stored == username) return true; // Signed out during the login: drop the orphaned token, unless another // account already stored its own. diff --git a/lib/api/marianumconnect/auth/session_validator.dart b/lib/api/marianumconnect/auth/session_validator.dart index 355b0bb..311db69 100644 --- a/lib/api/marianumconnect/auth/session_validator.dart +++ b/lib/api/marianumconnect/auth/session_validator.dart @@ -1,39 +1,43 @@ import 'dart:developer'; -import '../../../model/account_data.dart'; +import '../../../session/session.dart'; +import '../../../session/session_lifecycle.dart'; +import '../../../session/session_manager.dart'; import '../../errors/auth_exception.dart'; -import '../queries/auth_logout/auth_logout.dart'; import '../queries/auth_verify/auth_verify.dart'; -import 'token_storage.dart'; -/// Background credential probe — a server-side password rotation forces a -/// re-login on the next cold start even when the bearer token would still -/// be accepted. +/// Credential probe. A server-side password rotation forces a re-login on the +/// next cold start even when the bearer token would still be accepted. class SessionValidator { static Future probeStored({ required Future Function() onInvalidated, }) async { - if (!AccountData().isPopulated()) return; - // AuthVerify uses its own dio (bypassing the demo interceptor), so a demo - // session must be skipped here or its missing token would 401 into a logout. - if (AccountData().isDemo) return; - final username = AccountData().getUsername(); - final password = AccountData().getPassword(); - final epoch = AccountData().sessionEpoch; + final session = SessionManager().current; + // The probes use their own dio (bypassing the demo interceptor), so a demo + // session must be skipped or its missing token would 401 into a logout. + if (session == null || session.isDemo) return; + final epoch = SessionManager().sessionEpoch; try { - await AuthVerify().run(username: username, password: password); + switch (session) { + case CredentialSession(:final username, :final password): + await AuthVerify().run(username: username, password: password); + } } on AuthException catch (e) { if (e.statusCode != 401) return; // The probed account already signed out; the 401 must not sign out // whoever logged in meanwhile. - if (!AccountData().isCurrentSession(epoch)) return; - log('MC: stored credentials rejected — forcing re-login'); - await AuthLogout().run(); - await const MarianumConnectTokenStorage().clear(); - await AccountData().removeData(); + if (!SessionManager().isCurrentSession(epoch)) return; + log('MC: stored session rejected — forcing re-login'); + await SessionLifecycle.signOut( + notice: switch (session) { + CredentialSession() => + 'Deine Zugangsdaten wurden vom Server abgelehnt. Vermutlich ' + 'wurde dein Passwort geändert. Bitte melde dich erneut an.', + }, + ); await onInvalidated(); } catch (e) { - log('MC: background credential check failed (transient): $e'); + log('MC: background session check failed (transient): $e'); } } } diff --git a/lib/api/marianumconnect/auth/token_storage.dart b/lib/api/marianumconnect/auth/token_storage.dart index 1e19f64..24e85c8 100644 --- a/lib/api/marianumconnect/auth/token_storage.dart +++ b/lib/api/marianumconnect/auth/token_storage.dart @@ -1,5 +1,8 @@ +import 'package:dio/dio.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import '../../errors/auth_exception.dart'; + /// `first_unlock` accessibility so the token can be read during background /// requests (telemetry heartbeat, push-triggered syncs) after the first device /// unlock following a reboot. The keychain default (`whenUnlocked`) throws @@ -9,7 +12,7 @@ const IOSOptions _mcIosOptions = IOSOptions( ); /// Persists the Marianum-Connect bearer token in the platform keystore. Kept -/// separate from `AccountData` because the username/password live on (Nextcloud +/// separate from `SessionManager` because the username/password live on (Nextcloud /// + MHSL still need them) while the MC token is short-lived and per-endpoint. class MarianumConnectTokenStorage { static const _tokenKey = 'mc_bearer_token'; @@ -24,6 +27,18 @@ class MarianumConnectTokenStorage { Future readToken() => _storage.read(key: _tokenKey); + /// Request options carrying the stored token, for probes that bypass the + /// auth interceptor. Throws [AuthException] when no token is stored. + Future requireBearerOptions(String caller) async { + final token = await readToken(); + if (token == null || token.isEmpty) { + throw AuthException.unauthorized( + technicalDetails: '$caller: no bearer token in storage', + ); + } + return Options(headers: {'Authorization': 'Bearer $token'}); + } + Future readTokenId() => _storage.read(key: _tokenIdKey); Future readExpiresAt() async { diff --git a/lib/api/marianumconnect/marianumconnect_query.dart b/lib/api/marianumconnect/marianumconnect_query.dart index a69e373..8800dd6 100644 --- a/lib/api/marianumconnect/marianumconnect_query.dart +++ b/lib/api/marianumconnect/marianumconnect_query.dart @@ -1,5 +1,8 @@ +import 'dart:typed_data'; + import 'package:dio/dio.dart'; +import '../errors/app_exception.dart'; import 'errors/marianumconnect_error.dart'; import 'marianumconnect_api.dart'; import 'marianumconnect_endpoint.dart'; @@ -23,10 +26,14 @@ abstract class MarianumConnectQuery { try { return await body(); } on DioException catch (e) { - throw mapMarianumConnectError(e); + throw mapError(e); } } + /// The AppException a failed call surfaces as. Query families with domain + /// error codes override this instead of re-implementing [guard]. + AppException mapError(DioException error) => mapMarianumConnectError(error); + /// GETs [path] and parses the JSON object body with [fromJson]. Future getObject( String path, @@ -55,6 +62,16 @@ abstract class MarianumConnectQuery { .toList(); }); + /// GETs the raw bytes of [path] (files that need the bearer token). + Future getBytes(String path) => guard(() async { + final response = await dio.get>( + endpoint(path), + options: Options(responseType: ResponseType.bytes), + ); + final bytes = response.data!; + return bytes is Uint8List ? bytes : Uint8List.fromList(bytes); + }); + /// Formats [d] as an ISO `yyyy-MM-dd` day for MarianumConnect query params. String isoDate(DateTime d) => '${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'; diff --git a/lib/api/marianumconnect/queries/auth_verify/auth_verify.dart b/lib/api/marianumconnect/queries/auth_verify/auth_verify.dart index 561bc1e..e09e022 100644 --- a/lib/api/marianumconnect/queries/auth_verify/auth_verify.dart +++ b/lib/api/marianumconnect/queries/auth_verify/auth_verify.dart @@ -29,17 +29,12 @@ class AuthVerify extends MarianumConnectQuery { required String username, required String password, }) async { - final token = await _tokenStorage.readToken(); - if (token == null || token.isEmpty) { - throw AuthException.unauthorized( - technicalDetails: 'AuthVerify: no bearer token in storage', - ); - } + final options = await _tokenStorage.requireBearerOptions('AuthVerify'); return guard(() async { await dio.post( endpoint('auth/verify'), data: {'username': username, 'password': password}, - options: Options(headers: {'Authorization': 'Bearer $token'}), + options: options, ); }); } diff --git a/lib/api/marianumconnect/queries/get_newsletter_file/get_newsletter_file.dart b/lib/api/marianumconnect/queries/get_newsletter_file/get_newsletter_file.dart index ba530ff..0095053 100644 --- a/lib/api/marianumconnect/queries/get_newsletter_file/get_newsletter_file.dart +++ b/lib/api/marianumconnect/queries/get_newsletter_file/get_newsletter_file.dart @@ -1,7 +1,5 @@ import 'dart:typed_data'; -import 'package:dio/dio.dart'; - import '../../marianumconnect_query.dart'; /// Downloads the raw PDF bytes of a Marianum Message from @@ -15,11 +13,6 @@ class GetNewsletterFile extends MarianumConnectQuery { GetNewsletterFile(this.id, {super.dio}); - Future run() => guard(() async { - final response = await dio.get>( - endpoint('newsletter/${Uri.encodeComponent(id)}/file'), - options: Options(responseType: ResponseType.bytes), - ); - return Uint8List.fromList(response.data!); - }); + Future run() => + getBytes('newsletter/${Uri.encodeComponent(id)}/file'); } diff --git a/lib/api/marianumconnect/queries/get_ticker_page_file/get_ticker_page_file.dart b/lib/api/marianumconnect/queries/get_ticker_page_file/get_ticker_page_file.dart index 011db55..f95b67f 100644 --- a/lib/api/marianumconnect/queries/get_ticker_page_file/get_ticker_page_file.dart +++ b/lib/api/marianumconnect/queries/get_ticker_page_file/get_ticker_page_file.dart @@ -1,7 +1,5 @@ import 'dart:typed_data'; -import 'package:dio/dio.dart'; - import '../../marianumconnect_query.dart'; /// Downloads the raw bytes of a PROXIED_FILE ticker page from @@ -15,11 +13,6 @@ class GetTickerPageFile extends MarianumConnectQuery { GetTickerPageFile(this.slug, {super.dio}); - Future run() => guard(() async { - final response = await dio.get>( - endpoint('ticker/pages/${Uri.encodeComponent(slug)}/file'), - options: Options(responseType: ResponseType.bytes), - ); - return Uint8List.fromList(response.data!); - }); + Future run() => + getBytes('ticker/pages/${Uri.encodeComponent(slug)}/file'); } diff --git a/lib/api/marianumconnect/queries/telemetry_heartbeat/telemetry_device_id.dart b/lib/api/marianumconnect/queries/telemetry_heartbeat/telemetry_device_id.dart index f4504d3..ad89dee 100644 --- a/lib/api/marianumconnect/queries/telemetry_heartbeat/telemetry_device_id.dart +++ b/lib/api/marianumconnect/queries/telemetry_heartbeat/telemetry_device_id.dart @@ -1,7 +1,7 @@ -import 'dart:math'; - import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import '../../../../utils/random_id.dart'; + /// A stable, anonymous per-install identifier for telemetry. Generated once on /// first use (128 bits from a cryptographic RNG) and persisted in the secure /// keystore, so a device stays a single row across password rotations and FCM @@ -21,15 +21,9 @@ class TelemetryDeviceId { _cached = existing; return existing; } - final generated = _generate(); + final generated = randomHexId(); await _storage.write(key: _key, value: generated); _cached = generated; return generated; } - - static String _generate() { - final random = Random.secure(); - final bytes = List.generate(16, (_) => random.nextInt(256)); - return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); - } } diff --git a/lib/api/marianumconnect/queries/timetable_custom_events/custom_events_migration.dart b/lib/api/marianumconnect/queries/timetable_custom_events/custom_events_migration.dart index d26e008..8deebb7 100644 --- a/lib/api/marianumconnect/queries/timetable_custom_events/custom_events_migration.dart +++ b/lib/api/marianumconnect/queries/timetable_custom_events/custom_events_migration.dart @@ -2,8 +2,8 @@ import 'dart:developer'; import 'package:shared_preferences/shared_preferences.dart'; -import '../../../../model/account_data.dart'; -import '../../../demo/demo_mode.dart'; +import '../../../../session/session.dart'; +import '../../../../session/session_manager.dart'; import '../../../mhsl/custom_timetable_event/get/get_custom_timetable_event.dart'; import '../../../mhsl/custom_timetable_event/get/get_custom_timetable_event_params.dart'; import '../../../mhsl/custom_timetable_event/remove/remove_custom_timetable_event.dart'; @@ -26,28 +26,32 @@ class CustomEventsMigration { const CustomEventsMigration._(); static Future runOnce() async { - if (DemoMode.active) return; + // Only password accounts can derive the legacy identity. + final session = SessionManager().current; + if (session is! CredentialSession || session.isDemo) return; if (await _isDone()) return; - final epoch = AccountData().sessionEpoch; + final epoch = SessionManager().sessionEpoch; try { final response = await GetCustomTimetableEvent( - GetCustomTimetableEventParams(AccountData().getUserSecret()), + GetCustomTimetableEventParams(session.legacyUserSecret), ).run(); for (final event in response.events) { // The POST authenticates with whoever is signed in now; after a // sign-out the previous account's events would land in the next one. - if (!AccountData().isCurrentSession(epoch)) return; + if (!SessionManager().isCurrentSession(epoch)) return; await TimetableCustomEventsAdd().run(event); await RemoveCustomTimetableEvent( RemoveCustomTimetableEventParams(event.id), ).run(); } - if (!AccountData().isCurrentSession(epoch)) return; + if (!SessionManager().isCurrentSession(epoch)) return; await _markDone(); - log('Custom events migration: moved ${response.events.length} event(s) to Marianum-Connect.'); + log( + 'Custom events migration: moved ${response.events.length} event(s) to Marianum-Connect.', + ); } catch (e) { // Leave the flag unset so the next launch retries; the delete-after-post // above keeps a partial run duplicate-free. diff --git a/lib/api/request_cache.dart b/lib/api/request_cache.dart index e6c2b03..c797c2e 100644 --- a/lib/api/request_cache.dart +++ b/lib/api/request_cache.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'dart:convert'; -import '../model/account_data.dart'; +import '../session/session_manager.dart'; import 'api_response.dart'; import 'cache_store.dart'; import 'errors/parse_exception.dart'; @@ -47,7 +47,7 @@ abstract class RequestCache { static void ignore(Exception e) {} Future start(String document) async { - final epoch = AccountData().sessionEpoch; + final epoch = SessionManager().sessionEpoch; try { final entry = await CacheStore.instance.read(document); var lastUpdate = entry?.lastUpdate ?? 0; @@ -85,7 +85,7 @@ abstract class RequestCache { final newValue = await onLoad(); // The cache is shared, so a late response of a signed-out // account would otherwise be cached for the next one. - if (!AccountData().isCurrentSession(epoch)) { + if (!SessionManager().isCurrentSession(epoch)) { onError(const StaleSessionException()); return; } diff --git a/lib/app.dart b/lib/app.dart index 37d1fae..b8a0fc2 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -10,7 +10,6 @@ import 'api/marianumconnect/marianumconnect_api.dart'; import 'api/marianumconnect/queries/get_breakers/get_breakers_response.dart'; import 'api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart'; import 'main.dart'; -import 'model/account_data.dart'; import 'model/data_cleaner.dart'; import 'notification/notification_controller.dart'; import 'notification/notification_service.dart'; @@ -18,6 +17,7 @@ import 'notification/notification_tasks.dart'; import 'push/push_registration.dart'; import 'push/push_tap_router.dart'; import 'routing/app_routes.dart'; +import 'session/session_manager.dart'; import 'share_intent/share_intent_listener.dart'; import 'state/app/modules/app_modules.dart'; import 'state/app/modules/breaker/bloc/breaker_bloc.dart'; @@ -174,7 +174,7 @@ class _AppState extends State with WidgetsBindingObserver { _timetableWidgetSync = timetable.stream.listen((state) { final data = state.data; if (data is TimetableState && !state.isLoading) { - final epoch = AccountData().sessionEpoch; + final epoch = SessionManager().sessionEpoch; Debouncer.debounce( 'widgetPublish', const Duration(seconds: 1), @@ -194,7 +194,7 @@ class _AppState extends State with WidgetsBindingObserver { // frames are done (a fresh bloc emit in the meantime supersedes it). final initialData = timetable.state.data; if (initialData is TimetableState) { - final epoch = AccountData().sessionEpoch; + final epoch = SessionManager().sessionEpoch; Debouncer.debounce( 'widgetPublish', const Duration(seconds: 3), diff --git a/lib/background/widget_background_task.dart b/lib/background/widget_background_task.dart index 82f71f3..2a465bf 100644 --- a/lib/background/widget_background_task.dart +++ b/lib/background/widget_background_task.dart @@ -18,7 +18,8 @@ import '../api/marianumconnect/queries/timetable_get_week/timetable_get_week.dar import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event.dart'; import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_params.dart'; import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart'; -import '../model/account_data.dart'; +import '../session/session.dart'; +import '../session/session_manager.dart'; import '../widget_data/widget_data_mapper.dart'; import '../widget_data/widget_publisher.dart'; import '../widget_data/widget_sync.dart'; @@ -81,17 +82,17 @@ class WidgetBackgroundTask { /// Throws on fetch failure so the worker path can signal a retry. static Future runRefreshNow({bool force = false}) async { await WidgetSync.ensureInitialized(); - bool populated; + Session? session; try { // Bounded: a hanging keystore read must not stall the caller's budget // (FCM handler ~25s on iOS) forever. - populated = await AccountData().waitForPopulation().timeout( + session = await SessionManager().waitForLoad().timeout( const Duration(seconds: 10), ); } on TimeoutException { - populated = false; + session = null; } - if (!populated) { + if (session == null) { // Deliberately does NOT flip the widget to logged-out: a failed or slow // keychain read (locked iOS device during the 06:00 silent push) is // indistinguishable from "never logged in" here, and blanking the @@ -105,7 +106,7 @@ class WidgetBackgroundTask { log('[widget-refresh] snapshot is fresh, skipping refresh'); return; } - await _refresh(); + await _refresh(session); } static Future cancelAll() async { @@ -142,7 +143,7 @@ void _callbackDispatcher() { }); } -Future _refresh() async { +Future _refresh(Session session) async { await WidgetSync.ensureInitialized(); // The background isolate doesn't go through main.dart's BlocBuilder, so we // re-apply the endpoint the foreground last persisted. Without this the @@ -181,11 +182,14 @@ Future _refresh() async { final timegridFuture = _runOrNull( () => TimetableGetTimegrid().run(), ); - final customEventsFuture = _runOrNull( - () => GetCustomTimetableEvent( - GetCustomTimetableEventParams(AccountData().getUserSecret()), - ).run(), - ); + final customEventsFuture = switch (session) { + CredentialSession(:final legacyUserSecret) => + _runOrNull( + () => GetCustomTimetableEvent( + GetCustomTimetableEventParams(legacyUserSecret), + ).run(), + ), + }; final timetable = await timetableFuture; final subjects = await subjectsFuture; final rooms = await roomsFuture; diff --git a/lib/main.dart b/lib/main.dart index 6005a26..4d457c7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -26,7 +26,6 @@ import 'api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart import 'app.dart'; import 'background/widget_background_task.dart'; import 'firebase_options.dart'; -import 'model/account_data.dart'; import 'model/session_wipe.dart'; import 'notification/notification_service.dart'; import 'push/push_message_handler.dart'; @@ -34,6 +33,7 @@ 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'; @@ -119,7 +119,7 @@ void _installErrorHandlers() { Future main() async { log('MarianumMobile started'); WidgetsFlutterBinding.ensureInitialized(); - AccountData().markUiEngine(); + SessionManager().markUiEngine(); // Before any initialisation so startup failures reach the backend too. _installErrorHandlers(); @@ -152,12 +152,12 @@ Future main() async { _startupStep('documents dir', () async { AppPaths.documentsDir = (await getApplicationDocumentsDirectory()).path; }), - // The keychain may still be locked right after device unlock; AccountData + // 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', - AccountData().waitForPopulation, + SessionManager().waitForLoad, timeout: const Duration(seconds: 5), // Expected on every background wake of a locked device; not an error. report: false, @@ -224,7 +224,7 @@ Future main() async { // 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 (AccountData().isPopulated()) { + if (SessionManager().hasNextcloud) { unawaited( ListFilesCache.prefetchRootListing().onError( (e, _) => log('Files root prefetch failed: $e'), @@ -262,9 +262,9 @@ Future main() async { } AccountStatus _initialAccountStatus() { - final account = AccountData(); - if (account.isPopulated()) return AccountStatus.loggedIn; - return account.isLoaded ? AccountStatus.loggedOut : AccountStatus.undefined; + final session = SessionManager(); + if (session.isSignedIn) return AccountStatus.loggedIn; + return session.isLoaded ? AccountStatus.loggedOut : AccountStatus.undefined; } class Main extends StatefulWidget { @@ -293,13 +293,13 @@ class _MainState extends State
{ Jiffy.setLocale('de'); _lastStatus = context.read().state.status; - AccountData().waitForPopulation().then((value) { + SessionManager().waitForLoad().then((session) { if (!mounted) return; final accountBloc = context.read(); accountBloc.setStatus( - value ? AccountStatus.loggedIn : AccountStatus.loggedOut, + session != null ? AccountStatus.loggedIn : AccountStatus.loggedOut, ); - if (value) { + if (session != null) { _scheduleSessionValidation(accountBloc); // Cold start while already logged in: the account status doesn't // change, so the loggedIn listener below never fires — refresh @@ -323,7 +323,9 @@ class _MainState extends State
{ void _prefetchBaseData(BuildContext context) { context.read().refresh(); unawaited(context.read().refresh(silent: true)); - unawaited(ListFilesCache.prefetchRootListing()); + if (SessionManager().hasNextcloud) { + unawaited(ListFilesCache.prefetchRootListing()); + } } /// Registers/self-heals the push subscription whenever the backend advertises diff --git a/lib/model/account_data.dart b/lib/model/account_data.dart deleted file mode 100644 index 567b01e..0000000 --- a/lib/model/account_data.dart +++ /dev/null @@ -1,398 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:developer'; -import 'dart:io'; - -import 'package:crypto/crypto.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -import '../push/push_secure_storage.dart'; -import '../utils/exponential_backoff.dart'; - -class AccountData { - static const _usernameField = 'username'; - static const _passwordField = 'password'; - // App passwords live in the push-shared (group-scoped) keystore so the iOS - // Notification Service Extension can authenticate Nextcloud calls too. - // The talk password authenticates the second (apptype=talk) push - // registration — Nextcloud binds each push subscription to its session - // token, so two registrations need two app passwords. - static const _appPasswordField = 'nextcloud_app_password'; - static const _appPasswordTalkField = 'nextcloud_app_password_talk'; - // Marks accounts whose Nextcloud credentials came from Login Flow v2 (2FA): - // the real password is not valid against Nextcloud, only the flow-issued - // app password is — and no further app passwords can be minted silently. - static const _loginFlowField = 'nextcloud_login_flow'; - // Persists the demo session across cold starts (see DemoMode). - static const _demoField = 'is_demo'; - // Keeps isPopulated()/getPassword() valid; demo mode never uses a real one. - static const _demoPasswordPlaceholder = 'demo'; - - // `first_unlock` so a background launch on a locked device (silent push, - // BGAppRefresh) can still read the session. Items written by older versions - // carry the plugin default `unlocked` and are invisible to this instance - // until _migrateKeychainAccessibility moved them over. - static const FlutterSecureStorage _secureStorage = FlutterSecureStorage( - iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock), - ); - static const FlutterSecureStorage _legacySecureStorage = FlutterSecureStorage( - iOptions: IOSOptions(accessibility: KeychainAccessibility.unlocked), - ); - static const List _sessionFields = [ - _usernameField, - _passwordField, - _demoField, - _loginFlowField, - ]; - - static final AccountData _instance = AccountData._construct(); - Completer _populated = Completer(); - - factory AccountData() => _instance; - - AccountData._construct() { - unawaited(_loadWithRetry()); - } - - int _sessionEpoch = 0; - - /// Bumped on every sign-out. Async work captures it when it starts and - /// drops its result when it changed meanwhile, so a request of the previous - /// account cannot land in the next account's state or cache. - int get sessionEpoch => _sessionEpoch; - - bool isCurrentSession(int epoch) => epoch == _sessionEpoch; - - String? _username; - String? _password; - String? _appPassword; - String? _appPasswordTalk; - bool _isDemo = false; - bool _usesLoginFlow = false; - - /// True while the active session is a local demo session (see DemoMode). - bool get isDemo => _isDemo; - - /// True when the Nextcloud credentials were obtained via Login Flow v2 - /// (browser login, e.g. because the account has two-factor authentication). - /// In that mode the stored real password only authenticates MarianumConnect; - /// every Nextcloud call must use the flow-issued app password. - bool get usesLoginFlow => _usesLoginFlow; - - String getUsername() { - if (_username == null) throw Exception('Username not initialized'); - return _username!; - } - - String getPassword() { - if (_password == null) throw Exception('Password not initialized'); - return _password!; - } - - String getUserSecret() => sha512 - .convert(utf8.encode('${getUsername()}:${getPassword()}')) - .toString(); - - Future setData(String username, String password) async { - await _secureStorage.write(key: _usernameField, value: username); - await _secureStorage.write(key: _passwordField, value: password); - _username = username; - _password = password; - if (!_populated.isCompleted) _populated.complete(); - } - - /// Enters a local demo session for [username] — no real credentials or token; - /// every backend is served from fixtures while [isDemo] is true (see DemoMode). - Future setDemo(String username) async { - await _secureStorage.write(key: _usernameField, value: username); - await _secureStorage.write( - key: _passwordField, - value: _demoPasswordPlaceholder, - ); - await _secureStorage.write(key: _demoField, value: 'true'); - _username = username; - _password = _demoPasswordPlaceholder; - _isDemo = true; - if (!_populated.isCompleted) _populated.complete(); - } - - Future removeData() async { - _sessionEpoch++; - _populated = Completer(); - _username = null; - _password = null; - _appPassword = null; - _appPasswordTalk = null; - _isDemo = false; - _usesLoginFlow = false; - await _secureStorage.delete(key: _usernameField); - await _secureStorage.delete(key: _passwordField); - await _secureStorage.delete(key: _demoField); - await _secureStorage.delete(key: _loginFlowField); - await _clearAppPasswordStorage(); - await _clearAppPasswordTalkStorage(); - } - - /// Persists a freshly minted Nextcloud app password. After this every - /// [getBasicAuthHeader] call authenticates with the app password instead of - /// the real password. - Future setAppPassword(String appPassword) async { - _appPassword = appPassword; - try { - await pushSecureStorage.write(key: _appPasswordField, value: appPassword); - } on Object { - // Group-scoped keystore may be unavailable (e.g. iOS entitlement not yet - // provisioned). Keeping it in memory still lets this session use it. - } - } - - Future clearAppPassword() async { - _appPassword = null; - await _clearAppPasswordStorage(); - } - - /// Adopts an app password obtained via Login Flow v2 and switches the - /// account into flow mode (see [usesLoginFlow]). Any previously stored Talk - /// app password belonged to the old session era and is dropped — the second - /// (optional) flow pass stores a fresh one via [setAppPasswordTalk]. - Future setLoginFlow(String appPassword) async { - await setAppPassword(appPassword); - await clearAppPasswordTalk(); - _usesLoginFlow = true; - await _secureStorage.write(key: _loginFlowField, value: 'true'); - } - - bool hasAppPassword() => _appPassword != null && _appPassword!.isNotEmpty; - - /// Persists the app password backing the Talk push registration. - Future setAppPasswordTalk(String appPassword) async { - _appPasswordTalk = appPassword; - try { - await pushSecureStorage.write( - key: _appPasswordTalkField, - value: appPassword, - ); - } on Object { - // Group-scoped keystore may be unavailable — in-memory still works for - // this session, matching setAppPassword. - } - } - - Future clearAppPasswordTalk() async { - _appPasswordTalk = null; - await _clearAppPasswordTalkStorage(); - } - - bool hasAppPasswordTalk() => - _appPasswordTalk != null && _appPasswordTalk!.isNotEmpty; - - Future _clearAppPasswordStorage() async { - try { - await pushSecureStorage.delete(key: _appPasswordField); - } on Object { - // ignore — nothing stored or keystore unavailable - } - } - - Future _clearAppPasswordTalkStorage() async { - try { - await pushSecureStorage.delete(key: _appPasswordTalkField); - } on Object { - // ignore — nothing stored or keystore unavailable - } - } - - /// iOS keychain reads fail while protected data is unavailable (app launch - /// racing the unlock, background wake on a locked device). Without a retry - /// the completer never resolved and the app stayed on the launch screen. - Future _loadWithRetry() async { - for (var attempt = 1; !_populated.isCompleted; attempt++) { - try { - await _migrateAndLoad(); - return; - } catch (e, s) { - log('AccountData load failed (attempt $attempt): $e', stackTrace: s); - await Future.delayed(exponentialBackoff(attempt)); - } - } - } - - /// Stops waiting for the stored session; the app then behaves as logged - /// out. The keychain entries stay untouched so a later start can still - /// restore the session. - void abandonLoad() { - if (!_populated.isCompleted) _populated.complete(); - } - - Future _migrateAndLoad() async { - await _migrateFromLegacyStorage(); - await _migrateKeychainAccessibility(); - // Independent keystore reads, each a platform-channel round trip with - // decryption: issued together since this gates the first frame. - final (username, password, demo, loginFlow) = await ( - _secureStorage.read(key: _usernameField), - _secureStorage.read(key: _passwordField), - _secureStorage.read(key: _demoField), - _secureStorage.read(key: _loginFlowField), - ).wait; - _username = username; - _password = password; - _isDemo = demo == 'true'; - _usesLoginFlow = loginFlow == 'true'; - try { - final (appPassword, appPasswordTalk) = await ( - pushSecureStorage.read(key: _appPasswordField), - pushSecureStorage.read(key: _appPasswordTalkField), - ).wait; - _appPassword = appPassword; - _appPasswordTalk = appPasswordTalk; - } on Object { - _appPassword = null; - _appPasswordTalk = null; - } - if (!_populated.isCompleted) _populated.complete(); - } - - // Move credentials from the old SharedPreferences plain-text storage into the - // platform's secure keystore. Run once per install and clear the legacy keys. - Future _migrateFromLegacyStorage() async { - final prefs = await SharedPreferences.getInstance(); - final legacyUsername = prefs.getString(_usernameField); - final legacyPassword = prefs.getString(_passwordField); - if (legacyUsername == null || legacyPassword == null) return; - - final hasSecure = (await _secureStorage.read(key: _usernameField)) != null; - if (!hasSecure) { - await _secureStorage.write(key: _usernameField, value: legacyUsername); - await _secureStorage.write(key: _passwordField, value: legacyPassword); - } - await prefs.remove(_usernameField); - await prefs.remove(_passwordField); - } - - Future _migrateKeychainAccessibility() async { - if (!Platform.isIOS) return; - for (final field in _sessionFields) { - final value = await _legacySecureStorage.read(key: field); - if (value == null) continue; - // Same account+service: the legacy item has to go before the re-add. - await _legacySecureStorage.delete(key: field); - await _secureStorage.write(key: field, value: value); - } - } - - bool _isUiEngine = false; - - /// Called from `main()`; background entry points never run it. - void markUiEngine() => _isUiEngine = true; - - /// Username currently in the keystore. Other engines (widget task, push - /// isolates) sign out or in without this instance noticing. - Future readStoredUsername() => - _secureStorage.read(key: _usernameField); - - /// Re-reads the session for long-lived background engines: they load once - /// and would otherwise keep acting with an account that signed out in the - /// app meanwhile. Keeps the known state when the keystore is unreadable. - Future reloadFromStorage() async { - // The UI engine performs sign-in and sign-out itself, so its state is - // current; re-reading mid sign-out could resurrect the removed account. - if (_isUiEngine) return; - try { - final username = await _secureStorage.read(key: _usernameField); - final password = await _secureStorage.read(key: _passwordField); - final isDemo = (await _secureStorage.read(key: _demoField)) == 'true'; - final usesLoginFlow = - (await _secureStorage.read(key: _loginFlowField)) == 'true'; - String? appPassword; - String? appPasswordTalk; - try { - appPassword = await pushSecureStorage.read(key: _appPasswordField); - appPasswordTalk = await pushSecureStorage.read( - key: _appPasswordTalkField, - ); - } on Object { - // Group keystore unavailable: fall back to the real password. - } - if (username != _username) _sessionEpoch++; - _username = username; - _password = password; - _isDemo = isDemo; - _usesLoginFlow = usesLoginFlow; - _appPassword = appPassword; - _appPasswordTalk = appPasswordTalk; - if (!_populated.isCompleted) _populated.complete(); - } on Object catch (e) { - log('AccountData reload failed, keeping loaded state: $e'); - } - } - - Future waitForPopulation() async { - await _populated.future; - return isPopulated(); - } - - /// True once the stored session has been read (or given up on). - bool get isLoaded => _populated.isCompleted; - - bool isPopulated() => _username != null && _password != null; - - /// Returns the value for an HTTP `Authorization` header using HTTP Basic. - /// Prefer this over embedding credentials in URLs — error logs and crash - /// reports often capture the URL but not headers. - String getBasicAuthHeader() { - _requirePopulated(); - // Prefer the scoped app password once available; it survives real-password - // rotation and is what the push-v2 registration is bound to. - return _basicAuth(_appPassword ?? _password!); - } - - /// Basic-auth header using the Talk app password — authenticates the - /// apptype=talk push registration (and its unregister). Throws when the - /// talk password has not been minted yet; callers treat that as a failed - /// talk registration and retry on the next start. - String getTalkBasicAuthHeader() { - _requirePopulated(); - if (!hasAppPasswordTalk()) { - // Login-flow account whose second (talk) flow pass was skipped: no - // silent minting possible, the talk registration shares the single - // flow-issued credential. - if (_usesLoginFlow && hasAppPassword()) return _basicAuth(_appPassword!); - throw StateError('Talk app password not available yet'); - } - return _basicAuth(_appPasswordTalk!); - } - - /// Basic-auth header that always uses the real password. Needed exactly once, - /// to mint the app password via `core/getapppassword` (an app password cannot - /// mint another). - String getRealPasswordBasicAuthHeader() { - _requirePopulated(); - return _basicAuth(_password!); - } - - /// Secret authenticating against Nextcloud: the app password once available - /// (minted or flow-issued), otherwise the real password. Mirrors the - /// preference of [getBasicAuthHeader] for clients that need the raw secret - /// (WebDAV client construction). - String getNextcloudSecret() { - _requirePopulated(); - return _appPassword ?? _password!; - } - - void _requirePopulated() { - if (!isPopulated()) { - throw Exception( - 'AccountData (e.g. username or password) is not initialized!', - ); - } - } - - String _basicAuth(String secret) => - 'Basic ${base64Encode(utf8.encode('$_username:$secret'))}'; - - /// Convenience wrapper around [getBasicAuthHeader] returning a single-entry - /// header map ready to merge into HTTP request headers. - Map authHeaders() => {'Authorization': getBasicAuthHeader()}; -} diff --git a/lib/push/push_actions.dart b/lib/push/push_actions.dart index a2d1f42..6da0fe7 100644 --- a/lib/push/push_actions.dart +++ b/lib/push/push_actions.dart @@ -8,8 +8,8 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:http/http.dart' as http; import '../api/marianumcloud/nextcloud_ocs.dart'; -import '../model/account_data.dart'; import '../notification/notification_service.dart'; +import '../session/session_manager.dart'; import 'chat_thread_store.dart'; import 'nid_store.dart'; import 'push_renderer.dart'; @@ -38,7 +38,7 @@ void _plog(String message) { /// Handles Talk notification actions (inline reply, mark-as-read). Runs in the /// background isolate spawned by flutter_local_notifications, so it may not /// share any app state — it reads credentials straight from secure storage via -/// the [AccountData] singleton after awaiting population. +/// the [SessionManager] singleton after awaiting the stored session. /// /// The class-level `vm:entry-point` pragma is REQUIRED in addition to the one /// on [handleBackgroundResponse]: the callback is resolved via @@ -56,14 +56,14 @@ class PushActions { ) async { // The FLN action isolate starts WITHOUT main(): unlike the FCM background // isolate, plugins are not registered automatically there. Without this, - // AccountData's secure-storage/prefs reads throw or never complete → no + // The session's secure-storage/prefs reads throw or never complete → no // auth header, the Talk POST never happens and the RemoteInput spinner // runs forever. DartPluginRegistrant.ensureInitialized(); // The action engine lives as long as the process: without a reload a // reply would be sent with the account that was signed in when the // engine first started. - await AccountData().reloadFromStorage(); + await SessionManager().reloadFromStorage(); _plog( 'action=${response.actionId} payload=${response.payload} ' @@ -129,7 +129,8 @@ class PushActions { /// any) followed by the technical reason. static String actionFailureBody({String? lostText, required String detail}) { return [ - if (lostText != null && lostText.isNotEmpty) 'Deine Nachricht: „$lostText“', + if (lostText != null && lostText.isNotEmpty) + 'Deine Nachricht: „$lostText“', 'Grund: $detail', ].join('\n'); } @@ -192,7 +193,10 @@ class PushActions { static Future<({bool ok, String detail})> sendReply( String chatToken, String message, - ) => _ocsPost('apps/spreed/api/v1/chat/$chatToken', body: {'message': message}); + ) => _ocsPost( + 'apps/spreed/api/v1/chat/$chatToken', + body: {'message': message}, + ); static Future<({bool ok, String detail})> markRead(String chatToken) => _ocsPost('apps/spreed/api/v1/chat/$chatToken/read'); @@ -204,10 +208,10 @@ class PushActions { try { // Bounded: a hanging population (e.g. keystore issue) must fail the // action instead of leaving the notification spinner running forever. - final populated = await AccountData().waitForPopulation().timeout( + final session = await SessionManager().waitForLoad().timeout( const Duration(seconds: 10), ); - if (!populated) { + if (session?.nextcloud == null) { _plog('Push action $path aborted: credentials unreadable in isolate'); return ( ok: false, diff --git a/lib/push/push_device_info.dart b/lib/push/push_device_info.dart new file mode 100644 index 0000000..8a09cea --- /dev/null +++ b/lib/push/push_device_info.dart @@ -0,0 +1,15 @@ +import 'dart:io'; + +import 'package:package_info_plus/package_info_plus.dart'; + +/// Platform value MarianumConnect expects in push registrations. +String get pushPlatform => Platform.isIOS ? 'ios' : 'android'; + +/// App version sent along with push registrations; null when unavailable. +Future pushAppVersion() async { + try { + return (await PackageInfo.fromPlatform()).version; + } on Object { + return null; + } +} diff --git a/lib/push/push_message_handler.dart b/lib/push/push_message_handler.dart index f53c11b..9ac324c 100644 --- a/lib/push/push_message_handler.dart +++ b/lib/push/push_message_handler.dart @@ -4,8 +4,8 @@ import 'package:crypton/crypton.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import '../background/widget_background_task.dart'; -import '../model/account_data.dart'; import '../notification/notification_service.dart'; +import '../session/session_manager.dart'; import 'chat_thread_store.dart'; import 'nid_store.dart'; import 'push_decryptor.dart'; @@ -56,7 +56,7 @@ PushKind classifyPush(Map data) { @pragma('vm:entry-point') Future pushOnBackgroundMessage(RemoteMessage message) async { // This engine outlives sign-outs and logins in the app. - await AccountData().reloadFromStorage(); + await SessionManager().reloadFromStorage(); await NotificationService().initializeNotifications(); await PushRenderer.ensureChannels(); await PushMessageHandler().handle(message); diff --git a/lib/push/push_registration.dart b/lib/push/push_registration.dart index e9cd974..eaabb46 100644 --- a/lib/push/push_registration.dart +++ b/lib/push/push_registration.dart @@ -3,7 +3,6 @@ import 'dart:io'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:nextcloud/notifications.dart' show generatePushTokenHash; -import 'package:package_info_plus/package_info_plus.dart'; import '../api/demo/demo_mode.dart'; import '../api/marianumcloud/app_password/delete_app_password.dart'; @@ -11,9 +10,11 @@ import '../api/marianumcloud/app_password/get_app_password.dart'; import '../api/marianumconnect/marianumconnect_endpoint.dart'; import '../api/marianumconnect/queries/push_device_register/push_device_register.dart'; import '../api/marianumconnect/queries/push_device_unregister/push_device_unregister.dart'; -import '../model/account_data.dart'; import '../model/endpoint_data.dart'; +import '../session/nextcloud_credentials.dart'; +import '../session/session_manager.dart'; import 'nextcloud_push_api.dart'; +import 'push_device_info.dart'; import 'push_keypair.dart'; import 'push_registration_store.dart'; import 'push_registration_type.dart'; @@ -48,8 +49,6 @@ class PushRegistration { _store = store ?? const PushRegistrationStore(), _nextcloud = nextcloud ?? NextcloudPushApi(); - String get _platform => Platform.isIOS ? 'ios' : 'android'; - String get _talkUserAgent => Platform.isIOS ? talkUserAgentIos : talkUserAgentAndroid; @@ -63,20 +62,26 @@ class PushRegistration { /// slash) — persisted alongside the registration to detect endpoint changes. String get currentNcBaseUrl => 'https://${EndpointData().nextcloud().full()}'; + NextcloudCredentials? get _nextcloudOrNull => + SessionManager().current?.nextcloud; + /// Ensures the Nextcloud app password exists (idempotent, best-effort). Push /// registration binds to it, so it must be obtained before registering. Future ensureAppPassword() async { - if (AccountData().hasAppPassword()) return; - if (AccountData().usesLoginFlow) { + final nextcloud = _nextcloudOrNull; + if (nextcloud == null || nextcloud.hasAppPassword) return; + if (nextcloud.usesLoginFlow) { // Flow-Konten (2FA): Basic Auth mit dem echten Passwort wird abgelehnt, // stilles Minting ist unmöglich. Reparatur nur interaktiv über // Einstellungen → „Nextcloud neu verbinden". - log('Push: login-flow account without app password, cannot mint silently'); + log( + 'Push: login-flow account without app password, cannot mint silently', + ); return; } try { final appPassword = await GetAppPassword().run(); - await AccountData().setAppPassword(appPassword); + await SessionManager().setAppPassword(appPassword); } on Object catch (e) { log('Push: could not obtain app password (non-blocking): $e'); } @@ -85,15 +90,16 @@ class PushRegistration { /// Ensures the second app password backing the Talk registration exists /// (each `getapppassword` call with the real password mints a fresh one). Future ensureTalkAppPassword() async { - if (AccountData().hasAppPasswordTalk()) return; + final nextcloud = _nextcloudOrNull; + if (nextcloud == null || nextcloud.hasAppPasswordTalk) return; // Flow-Konten können still kein zweites App-Passwort münzen — das // Talk-Passwort kommt nur aus dem zweiten Login-Flow-Durchlauf; bis dahin // teilt sich die Talk-Registrierung das eine App-Passwort (siehe - // AccountData.getTalkBasicAuthHeader). - if (AccountData().usesLoginFlow) return; + // NextcloudCredentials.talkBasicAuthHeader). + if (nextcloud.usesLoginFlow) return; try { final appPassword = await GetAppPassword().run(); - await AccountData().setAppPasswordTalk(appPassword); + await SessionManager().setAppPasswordTalk(appPassword); } on Object catch (e) { log('Push: could not obtain talk app password (non-blocking): $e'); } @@ -107,7 +113,8 @@ class PushRegistration { /// fire-and-forget (and simply ignore the result). Future register() async { if (DemoMode.active) return false; - final epoch = AccountData().sessionEpoch; + if (_nextcloudOrNull == null) return false; + final epoch = SessionManager().sessionEpoch; final String? fcmToken; try { fcmToken = await FirebaseMessaging.instance.getToken(); @@ -135,16 +142,13 @@ class PushRegistration { return false; } - String? appVersion; - try { - appVersion = (await PackageInfo.fromPlatform()).version; - } on Object { - appVersion = null; - } + final appVersion = await pushAppVersion(); + // Re-read: the ensure* calls above may have swapped the credentials. + final nextcloud = SessionManager().requireNextcloud(); final types = registrationTypesFor( - usesLoginFlow: AccountData().usesLoginFlow, - hasTalkAppPassword: AccountData().hasAppPasswordTalk(), + usesLoginFlow: nextcloud.usesLoginFlow, + hasTalkAppPassword: nextcloud.hasAppPasswordTalk, ); if (!types.contains(PushRegistrationType.general)) { await _recordAttempt( @@ -183,7 +187,7 @@ class PushRegistration { devicePublicKeyPem: pems.publicKeyPem, proxyServer: proxyServer, authorizationHeader: isTalk - ? AccountData().getTalkBasicAuthHeader() + ? SessionManager().requireNextcloud().talkBasicAuthHeader : null, userAgent: isTalk ? _talkUserAgent : null, ); @@ -191,7 +195,7 @@ class PushRegistration { // Signed out while registering: persisting or announcing this // registration would keep delivering the previous account's pushes, // and logoutCleanup already ran so nothing would unregister it. - if (!AccountData().isCurrentSession(epoch)) return false; + if (!SessionManager().isCurrentSession(epoch)) return false; await _store.save( type: type, @@ -207,7 +211,7 @@ class PushRegistration { deviceIdentifierSignature: registration.signature, userPublicKey: registration.publicKey, pushToken: fcmToken, - platform: _platform, + platform: pushPlatform, registrationType: type.wireName, appVersion: appVersion, ); @@ -256,7 +260,7 @@ class PushRegistration { try { final endpoint = EndpointData().nextcloud(); await _store.saveNativeAuthContext( - username: AccountData().getUsername(), + username: SessionManager().requireNextcloud().username, baseUrl: 'https://${endpoint.full()}', ); } on Object catch (e) { @@ -274,7 +278,7 @@ class PushRegistration { // session token — each registration with its own app password. await _nextcloud.unregister( authorizationHeader: type == PushRegistrationType.talk - ? AccountData().getTalkBasicAuthHeader() + ? SessionManager().requireNextcloud().talkBasicAuthHeader : null, ); } on Object catch (e) { @@ -428,7 +432,7 @@ class PushRegistration { /// is called with), then clear them locally. Ordered so the proxy stops /// pushing before credentials are gone. Future logoutCleanup() async { - if (DemoMode.active) return; + if (DemoMode.active || _nextcloudOrNull == null) return; await unregister(); try { await DeleteAppPassword().run(); @@ -436,15 +440,16 @@ class PushRegistration { log('Push: delete app password failed: $e'); } try { - if (AccountData().hasAppPasswordTalk()) { + final nextcloud = SessionManager().requireNextcloud(); + if (nextcloud.hasAppPasswordTalk) { await DeleteAppPassword().run( - authorizationHeader: AccountData().getTalkBasicAuthHeader(), + authorizationHeader: nextcloud.talkBasicAuthHeader, ); } } on Object catch (e) { log('Push: delete talk app password failed: $e'); } - await AccountData().clearAppPassword(); - await AccountData().clearAppPasswordTalk(); + await SessionManager().clearAppPassword(); + await SessionManager().clearAppPasswordTalk(); } } diff --git a/lib/push/push_registration_store.dart b/lib/push/push_registration_store.dart index e5f3a18..3bdc372 100644 --- a/lib/push/push_registration_store.dart +++ b/lib/push/push_registration_store.dart @@ -24,7 +24,7 @@ class PushRegistrationStore { // (reply / mark-as-read) directly via URLSession while the Flutter engine is // not guaranteed to run. It needs the Nextcloud username and base URL from the // shared (group-scoped) keychain; the app password already lives there - // (AccountData writes `nextcloud_app_password` group-scoped). + // (SessionManager writes `nextcloud_app_password` group-scoped). static const _usernameKey = 'nextcloud_username'; static const _baseUrlKey = 'nextcloud_base_url'; // Mirror of the in-app notification toggle (`notificationSettings.enabled`), diff --git a/lib/push/push_secure_storage.dart b/lib/push/push_secure_storage.dart index 0ab50c9..9834edf 100644 --- a/lib/push/push_secure_storage.dart +++ b/lib/push/push_secure_storage.dart @@ -24,7 +24,7 @@ const IOSOptions kPushIosOptions = IOSOptions( ); /// Shared secure storage instance for all push key material and registration -/// bookkeeping. Kept separate from [AccountData]'s default storage because the +/// bookkeeping. Kept separate from the session's default storage because the /// entries here are group-scoped for NSE access. const FlutterSecureStorage pushSecureStorage = FlutterSecureStorage( iOptions: kPushIosOptions, diff --git a/lib/push/push_status.dart b/lib/push/push_status.dart index 6e5f73e..3537d37 100644 --- a/lib/push/push_status.dart +++ b/lib/push/push_status.dart @@ -1,6 +1,6 @@ import 'package:flutter/foundation.dart'; -import '../model/account_data.dart'; +import '../session/session_manager.dart'; import 'push_keypair.dart'; import 'push_registration.dart'; import 'push_registration_store.dart'; @@ -124,14 +124,15 @@ Future collectPushStatus({ lastRegistrationError: await store.lastRegistrationError(type), ); + final nextcloud = SessionManager().current?.nextcloud; return PushStatusReport( settingEnabled: settingEnabled, osPermission: await _osPermission(), serverCapability: !capabilitiesLoaded ? PushCheck.unknown : (capabilityPush ? PushCheck.ok : PushCheck.fail), - appPasswordPresent: AccountData().hasAppPassword(), - talkAppPasswordPresent: AccountData().hasAppPasswordTalk(), + appPasswordPresent: nextcloud?.hasAppPassword ?? false, + talkAppPasswordPresent: nextcloud?.hasAppPasswordTalk ?? false, keypairPresent: (await keypair.loadPublicKeyPem())?.isNotEmpty ?? false, general: await typeStatus(PushRegistrationType.general), talk: await typeStatus(PushRegistrationType.talk), diff --git a/lib/routing/app_routes.dart b/lib/routing/app_routes.dart index 7d64606..bfb8298 100644 --- a/lib/routing/app_routes.dart +++ b/lib/routing/app_routes.dart @@ -9,8 +9,8 @@ import '../api/marianumcloud/talk/room/get_room_response.dart'; import '../api/marianumconnect/marianumconnect_endpoint.dart'; import '../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart'; import '../main.dart'; -import '../model/account_data.dart'; import '../notification/notification_tasks.dart'; +import '../session/session_manager.dart'; import '../share_intent/pending_share.dart'; import '../share_intent/remote_file_ref.dart'; import '../state/app/modules/app_modules.dart'; @@ -395,7 +395,10 @@ class AppRoutes { // ChatBloc._loadChat with the freshly-fetched maxId — sending one // here too with the chat list's possibly-stale room.lastMessage.id // would race the fresh one and could regress the server cursor. - context.read().markRoomAsRead(room.token, room.lastMessage.id); + context.read().markRoomAsRead( + room.token, + room.lastMessage.id, + ); NotificationTasks.clearNotificationsForChat(room.token); TalkNavigator.pushSplitView( context, @@ -425,7 +428,8 @@ class AppRoutes { static ResolvedPendingChat? resolvePendingChat(BuildContext context) { final token = pendingChatToken.value; if (token == null) return null; - if (!AccountData().isPopulated()) return null; + final nextcloud = SessionManager().current?.nextcloud; + if (nextcloud == null) return null; final rooms = context.read().state.data?.rooms; final room = _findRoomByToken(rooms, token); @@ -438,7 +442,7 @@ class AppRoutes { ); return ResolvedPendingChat( room: room, - selfId: AccountData().getUsername(), + selfId: nextcloud.username, avatar: avatar, ); } diff --git a/lib/session/nextcloud_credentials.dart b/lib/session/nextcloud_credentials.dart new file mode 100644 index 0000000..aecd59e --- /dev/null +++ b/lib/session/nextcloud_credentials.dart @@ -0,0 +1,77 @@ +import 'dart:convert'; + +/// Nextcloud identity of a session. Immutable; the session manager swaps in a +/// new instance whenever an app password is minted or revoked. +class NextcloudCredentials { + final String username; + + /// The real account password. Invalid against Nextcloud when + /// [usesLoginFlow] is set (2FA accounts), where only [appPassword] works. + final String password; + final String? appPassword; + + /// Backs the second (apptype=talk) push registration — Nextcloud binds each + /// push subscription to its session token, so two registrations need two + /// app passwords. + final String? appPasswordTalk; + + /// True when the credentials came from Login Flow v2 (browser login, e.g. + /// because the account has two-factor authentication). + final bool usesLoginFlow; + + const NextcloudCredentials({ + required this.username, + required this.password, + this.appPassword, + this.appPasswordTalk, + this.usesLoginFlow = false, + }); + + bool get hasAppPassword => appPassword != null && appPassword!.isNotEmpty; + + bool get hasAppPasswordTalk => + appPasswordTalk != null && appPasswordTalk!.isNotEmpty; + + /// The app password once available (minted or flow-issued), otherwise the + /// real password. It survives real-password rotation and is what the push + /// registration is bound to. + String get secret => hasAppPassword ? appPassword! : password; + + /// HTTP Basic header value. Prefer headers over credentials in URLs — error + /// logs and crash reports often capture the URL but not headers. + String get basicAuthHeader => _basicAuth(secret); + + Map get authHeaders => {'Authorization': basicAuthHeader}; + + /// Authenticates the apptype=talk push registration (and its unregister). + /// Throws when the talk password has not been minted yet; callers treat that + /// as a failed talk registration and retry on the next start. + String get talkBasicAuthHeader { + if (hasAppPasswordTalk) return _basicAuth(appPasswordTalk!); + // Login-flow account whose second (talk) flow pass was skipped: no silent + // minting possible, the talk registration shares the flow credential. + if (usesLoginFlow && hasAppPassword) return _basicAuth(appPassword!); + throw StateError('Talk app password not available yet'); + } + + /// Always the real password. Needed to mint the app password via + /// `core/getapppassword` — an app password cannot mint another. + String get realPasswordBasicAuthHeader => _basicAuth(password); + + NextcloudCredentials copyWith({ + String? Function()? appPassword, + String? Function()? appPasswordTalk, + bool? usesLoginFlow, + }) => NextcloudCredentials( + username: username, + password: password, + appPassword: appPassword != null ? appPassword() : this.appPassword, + appPasswordTalk: appPasswordTalk != null + ? appPasswordTalk() + : this.appPasswordTalk, + usesLoginFlow: usesLoginFlow ?? this.usesLoginFlow, + ); + + String _basicAuth(String secret) => + 'Basic ${base64Encode(utf8.encode('$username:$secret'))}'; +} diff --git a/lib/session/session.dart b/lib/session/session.dart new file mode 100644 index 0000000..955ac38 --- /dev/null +++ b/lib/session/session.dart @@ -0,0 +1,64 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; + +import 'nextcloud_credentials.dart'; + +/// The signed-in account. Exactly one session is active at a time; features +/// check for the backend identities they need ([nextcloud]) instead of +/// assuming every account has all of them. +sealed class Session { + /// Local demo session: every backend is served from fixtures (see DemoMode). + final bool isDemo; + + const Session({this.isDemo = false}); + + /// Nextcloud identity, or null for accounts without one. + NextcloudCredentials? get nextcloud; +} + +/// Student, teacher or staff account: username + password, backed by +/// MarianumConnect and Nextcloud (with the same username and password). +final class CredentialSession extends Session { + @override + final NextcloudCredentials nextcloud; + + CredentialSession({ + required String username, + required String password, + String? appPassword, + String? appPasswordTalk, + bool usesLoginFlow = false, + super.isDemo, + }) : nextcloud = NextcloudCredentials( + username: username, + password: password, + appPassword: appPassword, + appPasswordTalk: appPasswordTalk, + usesLoginFlow: usesLoginFlow, + ); + + const CredentialSession._(this.nextcloud, {super.isDemo}); + + String get username => nextcloud.username; + + String get password => nextcloud.password; + + CredentialSession withNextcloud(NextcloudCredentials nextcloud) => + CredentialSession._(nextcloud, isDemo: isDemo); + + /// Legacy MHSL identity (`sha512(user:pass)`), only for the one-off custom + /// events migration. + String get legacyUserSecret => + sha512.convert(utf8.encode('$username:$password')).toString(); +} + +/// Thrown when a Nextcloud-only feature is reached with a session that has no +/// Nextcloud identity. Indicates a missing gate, not a user error. +class NextcloudUnavailableException implements Exception { + const NextcloudUnavailableException(); + + @override + String toString() => + 'NextcloudUnavailableException: session has no Nextcloud account'; +} diff --git a/lib/session/session_codec.dart b/lib/session/session_codec.dart new file mode 100644 index 0000000..92c9146 --- /dev/null +++ b/lib/session/session_codec.dart @@ -0,0 +1,54 @@ +import 'session.dart'; + +/// Keychain keys of the session. Names are frozen: installed versions and the +/// iOS AppDelegate/NSE read them, so renaming would log every user out. +abstract final class SessionKeys { + static const username = 'username'; + static const password = 'password'; + static const appPassword = 'nextcloud_app_password'; + static const appPasswordTalk = 'nextcloud_app_password_talk'; + static const loginFlow = 'nextcloud_login_flow'; + static const demo = 'is_demo'; + + // Absent on installs from before account kinds — see [decodeSession]. + static const kind = 'session_kind'; + + static const kindCredential = 'credential'; +} + +/// Rebuilds the session from raw keychain values. Installs from before +/// account kinds carry no [SessionKeys.kind]; a stored username and password +/// then mean a credential session, so existing users stay signed in. +Session? decodeSession(Map raw) { + final isDemo = raw[SessionKeys.demo] == 'true'; + switch (raw[SessionKeys.kind]) { + case null: + case SessionKeys.kindCredential: + final username = raw[SessionKeys.username]; + final password = raw[SessionKeys.password]; + if (username == null || password == null) return null; + return CredentialSession( + username: username, + password: password, + appPassword: raw[SessionKeys.appPassword], + appPasswordTalk: raw[SessionKeys.appPasswordTalk], + usesLoginFlow: raw[SessionKeys.loginFlow] == 'true', + isDemo: isDemo, + ); + default: + // Written by a newer app version; unknown here, treat as signed out. + return null; + } +} + +/// Keychain values for [session], excluding the group-scoped app passwords +/// (written separately so the iOS NSE can read them). `null` = delete. +Map encodeSessionFields(Session session) => switch (session) { + CredentialSession() => { + SessionKeys.kind: SessionKeys.kindCredential, + SessionKeys.username: session.username, + SessionKeys.password: session.password, + SessionKeys.demo: session.isDemo ? 'true' : null, + SessionKeys.loginFlow: session.nextcloud.usesLoginFlow ? 'true' : null, + }, +}; diff --git a/lib/session/session_lifecycle.dart b/lib/session/session_lifecycle.dart new file mode 100644 index 0000000..0526f0d --- /dev/null +++ b/lib/session/session_lifecycle.dart @@ -0,0 +1,29 @@ +import 'dart:developer'; + +import 'package:flutter/foundation.dart'; + +import '../api/marianumconnect/queries/auth_logout/auth_logout.dart'; +import '../push/push_registration.dart'; +import 'session_manager.dart'; + +abstract final class SessionLifecycle { + /// Why the last sign-out happened, when the user did not ask for it. The + /// login screen shows it once and clears it — without it an expired session + /// just drops the user on the login screen with no explanation. + static final ValueNotifier signOutNotice = ValueNotifier(null); + + /// Ordered teardown: unregister push and revoke the Nextcloud app passwords + /// (while those credentials still exist), then revoke the MC bearer token, + /// finally wipe the local session. Each step is best-effort so an offline + /// sign-out still reaches a clean local state. + static Future signOut({String? notice}) async { + signOutNotice.value = notice; + try { + await PushRegistration().logoutCleanup(); + } on Object catch (e) { + log('Sign-out: push cleanup failed: $e'); + } + await AuthLogout().run(); + await SessionManager().signOut(); + } +} diff --git a/lib/session/session_manager.dart b/lib/session/session_manager.dart new file mode 100644 index 0000000..44c17e8 --- /dev/null +++ b/lib/session/session_manager.dart @@ -0,0 +1,288 @@ +import 'dart:async'; +import 'dart:developer'; +import 'dart:io'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../push/push_secure_storage.dart'; +import '../utils/exponential_backoff.dart'; +import 'nextcloud_credentials.dart'; +import 'session.dart'; +import 'session_codec.dart'; + +/// Owns the active [Session] and its persistence. One instance per isolate; +/// the widget background isolate reads the same keychain. +class SessionManager { + // `first_unlock` so a background launch on a locked device (silent push, + // BGAppRefresh) can still read the session. Items written by older versions + // carry the plugin default `unlocked` and are invisible to this instance + // until _migrateKeychainAccessibility moved them over. + static const FlutterSecureStorage _secureStorage = FlutterSecureStorage( + iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock), + ); + static const FlutterSecureStorage _legacySecureStorage = FlutterSecureStorage( + iOptions: IOSOptions(accessibility: KeychainAccessibility.unlocked), + ); + static const List _sessionFields = [ + SessionKeys.kind, + SessionKeys.username, + SessionKeys.password, + SessionKeys.demo, + SessionKeys.loginFlow, + ]; + + static final SessionManager _instance = SessionManager._(); + factory SessionManager() => _instance; + + SessionManager._() { + unawaited(_loadWithRetry()); + } + + Completer _loaded = Completer(); + Session? _current; + + Session? get current => _current; + + int _sessionEpoch = 0; + + /// Bumped whenever the signed-in account changes. Async work captures it when it starts and drops its + /// result when it changed meanwhile, so a request of the previous account + /// cannot land in the next account's state or cache. + int get sessionEpoch => _sessionEpoch; + + bool isCurrentSession(int epoch) => epoch == _sessionEpoch; + + bool _isUiEngine = false; + + /// Called from `main()`; background entry points never run it. + void markUiEngine() => _isUiEngine = true; + + bool get isSignedIn => _current != null; + + bool get isDemo => _current?.isDemo ?? false; + + /// Whether the session has a Nextcloud identity (Talk, Files, NC push). + bool get hasNextcloud => _current?.nextcloud != null; + + /// True once the stored session has been read (or given up on). + bool get isLoaded => _loaded.isCompleted; + + /// Resolves once the stored session is known. After [signOut] it stays + /// pending until the next sign-in. + Future waitForLoad() async { + await _loaded.future; + return _current; + } + + /// Stops waiting for the stored session; the app then behaves as signed + /// out. The keychain entries stay untouched so a later start can still + /// restore the session. + void abandonLoad() { + if (!_loaded.isCompleted) _loaded.complete(); + } + + NextcloudCredentials requireNextcloud() => + _current?.nextcloud ?? (throw const NextcloudUnavailableException()); + + /// Replaces any stored session completely; no prior [signOut] needed. + Future signIn(Session session) async { + if (!_isSameAccount(_current, session)) _sessionEpoch++; + await Future.wait([ + for (final MapEntry(:key, :value) in encodeSessionFields(session).entries) + _writeSecret(key, value), + _writeGroupSecret( + SessionKeys.appPassword, + session.nextcloud?.appPassword, + ), + _writeGroupSecret( + SessionKeys.appPasswordTalk, + session.nextcloud?.appPasswordTalk, + ), + ]); + _current = session; + if (!_loaded.isCompleted) _loaded.complete(); + } + + Future signOut() async { + _sessionEpoch++; + _loaded = Completer(); + _current = null; + await Future.wait([ + for (final field in _sessionFields) _secureStorage.delete(key: field), + _writeGroupSecret(SessionKeys.appPassword, null), + _writeGroupSecret(SessionKeys.appPasswordTalk, null), + ]); + } + + static bool _isSameAccount(Session? a, Session? b) => switch ((a, b)) { + (final CredentialSession a, final CredentialSession b) => + a.username == b.username && a.isDemo == b.isDemo, + _ => a == b, + }; + + /// Persists a freshly minted Nextcloud app password; from then on every + /// Nextcloud call authenticates with it instead of the real password. + Future setAppPassword(String appPassword) async { + _updateNextcloud((nc) => nc.copyWith(appPassword: () => appPassword)); + await _writeGroupSecret(SessionKeys.appPassword, appPassword); + } + + Future clearAppPassword() async { + _updateNextcloud((nc) => nc.copyWith(appPassword: () => null)); + await _writeGroupSecret(SessionKeys.appPassword, null); + } + + /// Adopts an app password obtained via Login Flow v2 and switches the + /// account into flow mode. A previously stored Talk app password belonged + /// to the old session era and is dropped — the second (optional) flow pass + /// stores a fresh one via [setAppPasswordTalk]. + Future setLoginFlow(String appPassword) async { + await setAppPassword(appPassword); + await clearAppPasswordTalk(); + _updateNextcloud((nc) => nc.copyWith(usesLoginFlow: true)); + await _secureStorage.write(key: SessionKeys.loginFlow, value: 'true'); + } + + Future setAppPasswordTalk(String appPassword) async { + _updateNextcloud((nc) => nc.copyWith(appPasswordTalk: () => appPassword)); + await _writeGroupSecret(SessionKeys.appPasswordTalk, appPassword); + } + + Future clearAppPasswordTalk() async { + _updateNextcloud((nc) => nc.copyWith(appPasswordTalk: () => null)); + await _writeGroupSecret(SessionKeys.appPasswordTalk, null); + } + + void _updateNextcloud( + NextcloudCredentials Function(NextcloudCredentials) update, + ) { + final session = _current; + if (session is CredentialSession) { + _current = session.withNextcloud(update(session.nextcloud)); + } + } + + Future _writeSecret(String key, String? value) => value == null + ? _secureStorage.delete(key: key) + : _secureStorage.write(key: key, value: value); + + // App passwords live in the push-shared (group-scoped) keystore so the iOS + // Notification Service Extension can authenticate Nextcloud calls too. That + // keystore may be unavailable (entitlement not provisioned); the in-memory + // copy still serves this session. + Future _writeGroupSecret(String key, String? value) async { + try { + if (value == null) { + await pushSecureStorage.delete(key: key); + } else { + await pushSecureStorage.write(key: key, value: value); + } + } on Object { + // ignore — see above + } + } + + /// iOS keychain reads fail while protected data is unavailable (app launch + /// racing the unlock, background wake on a locked device). Without a retry + /// the completer never resolved and the app stayed on the launch screen. + Future _loadWithRetry() async { + for (var attempt = 1; !_loaded.isCompleted; attempt++) { + try { + await _migrateAndLoad(); + return; + } catch (e, s) { + log('Session load failed (attempt $attempt): $e', stackTrace: s); + await Future.delayed(exponentialBackoff(attempt)); + } + } + } + + Future _migrateAndLoad() async { + await _migrateFromLegacyStorage(); + await _migrateKeychainAccessibility(); + _current = await _readActive(); + if (!_loaded.isCompleted) _loaded.complete(); + } + + /// The session in the active slots. On the startup critical path (and every + /// background wake): read in parallel instead of one keychain round-trip + /// after the other. + Future _readActive() async { + final values = await Future.wait( + _sessionFields.map((field) => _secureStorage.read(key: field)), + ); + final raw = Map.fromIterables(_sessionFields, values); + try { + final (appPassword, appPasswordTalk) = await ( + pushSecureStorage.read(key: SessionKeys.appPassword), + pushSecureStorage.read(key: SessionKeys.appPasswordTalk), + ).wait; + raw[SessionKeys.appPassword] = appPassword; + raw[SessionKeys.appPasswordTalk] = appPasswordTalk; + } on Object { + // Group keystore unavailable: fall back to the real password. + } + return decodeSession(raw); + } + + /// Username currently in the keystore. Other engines (widget task, push + /// isolates) sign out or in without this instance noticing. + Future readStoredUsername() => + _secureStorage.read(key: SessionKeys.username); + + /// Re-reads the session for long-lived background engines: they load once + /// and would otherwise keep acting with an account that signed out in the + /// app meanwhile. Keeps the known state when the keystore is unreadable. + Future reloadFromStorage() async { + // The UI engine performs sign-in and sign-out itself, so its state is + // current; re-reading mid sign-out could resurrect the removed account. + if (_isUiEngine) return; + try { + final session = await _readActive(); + if (!_isSameAccount(_current, session)) _sessionEpoch++; + _current = session; + if (!_loaded.isCompleted) _loaded.complete(); + } on Object catch (e) { + log('Session reload failed, keeping loaded state: $e'); + } + } + + // Move credentials from the old SharedPreferences plain-text storage into the + // platform's secure keystore. Run once per install and clear the legacy keys. + Future _migrateFromLegacyStorage() async { + final prefs = await SharedPreferences.getInstance(); + final legacyUsername = prefs.getString(SessionKeys.username); + final legacyPassword = prefs.getString(SessionKeys.password); + if (legacyUsername == null || legacyPassword == null) return; + + final hasSecure = + (await _secureStorage.read(key: SessionKeys.username)) != null; + if (!hasSecure) { + await _secureStorage.write( + key: SessionKeys.username, + value: legacyUsername, + ); + await _secureStorage.write( + key: SessionKeys.password, + value: legacyPassword, + ); + } + await prefs.remove(SessionKeys.username); + await prefs.remove(SessionKeys.password); + } + + Future _migrateKeychainAccessibility() async { + if (!Platform.isIOS) return; + final legacyValues = await Future.wait( + _sessionFields.map((field) => _legacySecureStorage.read(key: field)), + ); + for (final (i, field) in _sessionFields.indexed) { + final value = legacyValues[i]; + if (value == null) continue; + // Same account+service: the legacy item has to go before the re-add. + await _legacySecureStorage.delete(key: field); + await _secureStorage.write(key: field, value: value); + } + } +} diff --git a/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart index 78df01d..c09ea2a 100644 --- a/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart +++ b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart @@ -5,7 +5,7 @@ import 'package:hydrated_bloc/hydrated_bloc.dart'; import '../../../../../api/errors/error_mapper.dart'; import '../../../../../api/errors/stale_session_exception.dart'; -import '../../../../../model/account_data.dart'; +import '../../../../../session/session_manager.dart'; import '../../../../../utils/session_single_flight.dart'; import '../../loadable_state/loadable_state.dart'; import '../../loadable_state/loading_error.dart'; @@ -132,12 +132,12 @@ abstract class LoadableHydratedBloc< /// its async continuations, are dropped once the account signed out, so a /// late response of the previous account cannot refill the reset bloc. R runInSession(R Function() body) => - runZoned(body, zoneValues: {_sessionKey: AccountData().sessionEpoch}); + runZoned(body, zoneValues: {_sessionKey: SessionManager().sessionEpoch}); @override void add(LoadableHydratedBlocEvent event) { final epoch = Zone.current[_sessionKey]; - if (epoch is int && !AccountData().isCurrentSession(epoch)) return; + if (epoch is int && !SessionManager().isCurrentSession(epoch)) return; super.add(event); } diff --git a/lib/state/app/modules/capabilities/bloc/capabilities_cubit.dart b/lib/state/app/modules/capabilities/bloc/capabilities_cubit.dart index 1bc141b..a7a516c 100644 --- a/lib/state/app/modules/capabilities/bloc/capabilities_cubit.dart +++ b/lib/state/app/modules/capabilities/bloc/capabilities_cubit.dart @@ -5,7 +5,7 @@ import 'package:hydrated_bloc/hydrated_bloc.dart'; import '../../../../../api/demo/data/demo_capabilities.dart'; import '../../../../../api/demo/demo_mode.dart'; import '../../../../../api/marianumconnect/queries/get_capabilities/get_capabilities.dart'; -import '../../../../../model/account_data.dart'; +import '../../../../../session/session_manager.dart'; import 'capabilities_state.dart'; /// Holds the current user's mobile capability flags. Hydrated so the last @@ -35,12 +35,12 @@ class CapabilitiesCubit extends HydratedCubit { emit(DemoCapabilities.state()); return; } - final epoch = AccountData().sessionEpoch; + final epoch = SessionManager().sessionEpoch; try { final response = await GetCapabilities().run(); // A slow answer for the previous account must not decide the modules // of the next one. - if (!AccountData().isCurrentSession(epoch)) return; + if (!SessionManager().isCurrentSession(epoch)) return; emit( CapabilitiesState( viewForeignTimetables: response.viewForeignTimetables, diff --git a/lib/state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart b/lib/state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart index c2e34be..af51c92 100644 --- a/lib/state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart +++ b/lib/state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart @@ -5,7 +5,7 @@ import 'package:hydrated_bloc/hydrated_bloc.dart'; import '../../../../../api/demo/data/demo_capabilities.dart'; import '../../../../../api/demo/demo_mode.dart'; import '../../../../../api/marianumcloud/capabilities/get_nextcloud_capabilities.dart'; -import '../../../../../model/account_data.dart'; +import '../../../../../session/session_manager.dart'; import 'nextcloud_capabilities_state.dart'; /// Holds the current user's Nextcloud `files_sharing` capabilities. Hydrated so @@ -60,16 +60,17 @@ class NextcloudCapabilitiesCubit /// Refreshes capabilities from the server. On any failure the previously /// hydrated flags are kept but the state is marked `loaded`. Future load() async { + if (!SessionManager().hasNextcloud) return; if (DemoMode.active) { emit(DemoNextcloudCapabilities.state()); return; } - final epoch = AccountData().sessionEpoch; + final epoch = SessionManager().sessionEpoch; try { final caps = await GetNextcloudCapabilities().run(); // A slow answer for the previous account must not decide the modules // of the next one. - if (!AccountData().isCurrentSession(epoch)) return; + if (!SessionManager().isCurrentSession(epoch)) return; emit( NextcloudCapabilitiesState( apiEnabled: caps.apiEnabled, diff --git a/lib/utils/downloads/download_manager.dart b/lib/utils/downloads/download_manager.dart index 7b3959a..d7f2605 100644 --- a/lib/utils/downloads/download_manager.dart +++ b/lib/utils/downloads/download_manager.dart @@ -6,8 +6,8 @@ import 'package:background_downloader/background_downloader.dart' as bd; import 'package:flutter/foundation.dart'; import '../../api/marianumcloud/webdav/webdav_api.dart'; -import '../../model/account_data.dart'; import '../../notification/notification_service.dart'; +import '../../session/session_manager.dart'; import '../../share_intent/remote_file_ref.dart'; import 'download_job.dart'; @@ -116,7 +116,7 @@ class DownloadManager { final encodedPath = Uri.encodeComponent(remotePath).replaceAll('%2F', '/'); final task = bd.DownloadTask( url: '${WebdavApi.buildWebdavUrl()}$encodedPath', - headers: AccountData().authHeaders(), + headers: SessionManager().requireNextcloud().authHeaders, filename: name, baseDirectory: bd.BaseDirectory.temporary, directory: _directory, diff --git a/lib/utils/random_id.dart b/lib/utils/random_id.dart new file mode 100644 index 0000000..ffbb21a --- /dev/null +++ b/lib/utils/random_id.dart @@ -0,0 +1,19 @@ +import 'dart:math'; + +/// Random hex id from a cryptographic RNG, e.g. for per-install identifiers. +String randomHexId({int bytes = 16}) { + final random = Random.secure(); + return List.generate( + bytes, + (_) => random.nextInt(256), + ).map((b) => b.toRadixString(16).padLeft(2, '0')).join(); +} + +/// Random RFC 4122 version-4 UUID, for ids a server expects in UUID form. +String randomUuidV4() { + final hex = randomHexId(); + final variant = (int.parse(hex[16], radix: 16) & 0x3 | 0x8).toRadixString(16); + return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-' + '4${hex.substring(13, 16)}-$variant${hex.substring(17, 20)}-' + '${hex.substring(20)}'; +} diff --git a/lib/utils/session_single_flight.dart b/lib/utils/session_single_flight.dart index 5ee8d2e..ad159988 100644 --- a/lib/utils/session_single_flight.dart +++ b/lib/utils/session_single_flight.dart @@ -1,4 +1,4 @@ -import '../model/account_data.dart'; +import '../session/session_manager.dart'; /// Joins concurrent calls into the one already running for the same account /// session. A run left over from a signed-out session never blocks the next @@ -8,7 +8,7 @@ class SessionSingleFlight { int? _epoch; Future run(Future Function() action) { - final epoch = AccountData().sessionEpoch; + final epoch = SessionManager().sessionEpoch; final running = _running; if (running != null && _epoch == epoch) return running; _epoch = epoch; diff --git a/lib/view/login/account_loading_screen.dart b/lib/view/login/account_loading_screen.dart index 649f175..944ae05 100644 --- a/lib/view/login/account_loading_screen.dart +++ b/lib/view/login/account_loading_screen.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import '../../model/account_data.dart'; +import '../../session/session_manager.dart'; import '../../theming/light_app_theme.dart'; import '../../widget/app_progress_indicator.dart'; @@ -61,7 +61,7 @@ class _AccountLoadingScreenState extends State { ), const SizedBox(height: 8), TextButton( - onPressed: AccountData().abandonLoad, + onPressed: SessionManager().abandonLoad, style: TextButton.styleFrom(foregroundColor: Colors.white), child: const Text('Zur Anmeldung'), ), diff --git a/lib/view/login/login.dart b/lib/view/login/login.dart index 8b0262e..f559768 100644 --- a/lib/view/login/login.dart +++ b/lib/view/login/login.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../background/widget_background_task.dart'; +import '../../session/session_lifecycle.dart'; import '../../state/app/modules/account/bloc/account_bloc.dart'; import '../../state/app/modules/account/bloc/account_state.dart'; import '../../state/app/modules/settings/bloc/settings_cubit.dart'; @@ -11,6 +12,7 @@ import '../../storage/dev_tools_settings.dart'; import '../../storage/settings.dart' as model; import '../../theming/light_app_theme.dart'; import '../../utils/haptics.dart'; +import '../../widget/info_dialog.dart'; import '../pages/settings/widgets/endpoint_picker.dart'; import 'login_controller.dart'; import 'post_login_splash.dart'; @@ -34,6 +36,21 @@ class _LoginState extends State with SingleTickerProviderStateMixin { value: 1, ); + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _showSignOutNotice()); + } + + /// An involuntary sign-out (expired token, rotated password) otherwise just + /// drops the user here without a word. + void _showSignOutNotice() { + final notice = SessionLifecycle.signOutNotice.value; + if (notice == null || !mounted) return; + SessionLifecycle.signOutNotice.value = null; + InfoDialog.show(context, notice, title: 'Erneut anmelden'); + } + @override void didChangeDependencies() { super.didChangeDependencies(); diff --git a/lib/view/login/login_controller.dart b/lib/view/login/login_controller.dart index ee6c419..f3c781b 100644 --- a/lib/view/login/login_controller.dart +++ b/lib/view/login/login_controller.dart @@ -10,8 +10,9 @@ import '../../api/marianumconnect/auth/device_token_name.dart'; import '../../api/marianumconnect/auth/token_storage.dart'; import '../../api/marianumconnect/queries/auth_login/auth_login.dart'; import '../../api/marianumconnect/queries/auth_logout/auth_logout.dart'; -import '../../model/account_data.dart'; import '../../model/session_wipe.dart'; +import '../../session/session.dart'; +import '../../session/session_manager.dart'; import '../../widget_data/widget_sync.dart'; /// Outcome of a login attempt. @@ -56,25 +57,18 @@ class LoginController extends ChangeNotifier { // Demo login: the prefix enters local demo mode, password ignored, no // network (see DemoMode). if (DemoMode.matches(user)) { - await AccountData().removeData(); - await const MarianumConnectTokenStorage().clear(); - await WidgetSync.clear(); - await WidgetSync.triggerUpdate(); - await AccountData().setDemo(user); + await _discardPreviousAccount(); + await SessionManager().signIn( + CredentialSession(username: user, password: 'demo', isDemo: true), + ); _loading = false; notifyListeners(); return LoginResult.success; } + var signedIn = false; try { - await AccountData().removeData(); - // Vorherigen Token revoken bevor wir einen neuen anfordern — ein altes - // Account hätte sonst noch einen aktiven Token in api_tokens. - await const MarianumConnectTokenStorage().clear(); - // Widget-Snapshot löschen, sonst blitzt nach Account-Wechsel kurz der - // Stundenplan des vorigen Users auf dem Home-Bildschirm. - await WidgetSync.clear(); - await WidgetSync.triggerUpdate(); + await _discardPreviousAccount(); // AuthLogin = Credential-Probe + Token-Create in einem Call. // 401 hier heißt: falsches Passwort. await AuthLogin().run( @@ -82,7 +76,10 @@ class LoginController extends ChangeNotifier { password: password, tokenName: await DeviceTokenName.resolve(), ); - await AccountData().setData(user, password); + await SessionManager().signIn( + CredentialSession(username: user, password: password), + ); + signedIn = true; // Mint the Nextcloud app password now — it doubles as the Nextcloud // credential probe: a rejection means 2FA is active (or the NC password // diverges) and the login must finish interactively in the browser. @@ -92,7 +89,9 @@ class LoginController extends ChangeNotifier { return ncReady ? LoginResult.success : LoginResult.nextcloudLoginRequired; } catch (e) { log(e.toString()); - await AccountData().removeData(); + // Only the account signed in by this attempt; while adding an account + // the active one is parked and restored on cancel. + if (signedIn) await SessionManager().signOut(); await const MarianumConnectTokenStorage().clear(); final isWrongCredentials = e is AuthException && e.statusCode == 401; _errorMessage = isWrongCredentials @@ -105,6 +104,17 @@ class LoginController extends ChangeNotifier { } } + /// Vorherigen Token verwerfen, bevor ein neuer angefordert wird, und den + /// Widget-Snapshot löschen — sonst blitzt nach einem Account-Wechsel kurz + /// der Stundenplan des vorigen Users auf dem Home-Bildschirm. Die Session + /// selbst überschreibt signIn vollständig; beim Hinzufügen eines Kontos + /// liegt der Token des aktiven Kontos schon in dessen Tresor. + Future _discardPreviousAccount() async { + await const MarianumConnectTokenStorage().clear(); + await WidgetSync.clear(); + await WidgetSync.triggerUpdate(); + } + /// Tries to mint the Nextcloud app password with the just-verified password. /// `false` = Nextcloud rejected the credentials → Login Flow v2 required. /// Transport/server problems stay non-blocking (like the previous @@ -112,7 +122,7 @@ class LoginController extends ChangeNotifier { Future _prepareNextcloudAppPassword() async { try { final appPassword = await GetAppPassword().run(); - await AccountData().setAppPassword(appPassword); + await SessionManager().setAppPassword(appPassword); return true; } on AuthException { return false; @@ -131,7 +141,7 @@ class LoginController extends ChangeNotifier { } on Object catch (e) { log('Login rollback: MC logout failed: $e'); } - await AccountData().removeData(); + await SessionManager().signOut(); await const MarianumConnectTokenStorage().clear(); _errorMessage = 'Die Anmeldung wurde abgebrochen — dein Konto erfordert die Bestätigung im Browser.'; diff --git a/lib/view/login/nextcloud_login_flow_page.dart b/lib/view/login/nextcloud_login_flow_page.dart index 57d1aed..08f5ba0 100644 --- a/lib/view/login/nextcloud_login_flow_page.dart +++ b/lib/view/login/nextcloud_login_flow_page.dart @@ -7,7 +7,7 @@ import 'package:flutter/material.dart'; import '../../api/errors/error_mapper.dart'; import '../../api/marianumcloud/app_password/delete_app_password.dart'; import '../../api/marianumcloud/login_flow/login_flow_api.dart'; -import '../../model/account_data.dart'; +import '../../session/session_manager.dart'; import '../../utils/url_opener.dart'; import '../../widget/app_progress_indicator.dart'; @@ -19,7 +19,7 @@ enum _FlowStep { primary, talk } /// Runs the Nextcloud Login Flow v2: opens the browser login, polls until the /// user confirmed it there (2FA happens inside the browser) and adopts the -/// returned app password via [AccountData.setLoginFlow]. A second, skippable +/// returned app password via [SessionManager.setLoginFlow]. A second, skippable /// pass mints the Talk app password so flow accounts keep BOTH push /// subscriptions (see PushRegistrationType). Pops `true` once the primary /// credential was adopted, `false`/`null` when the user backs out before that. @@ -104,7 +104,7 @@ class _NextcloudLoginFlowPageState extends State final credentials = await _api.poll(flow); if (credentials == null || _finished || !mounted) return; if (!LoginFlowApi.loginNameMatches( - expected: AccountData().getUsername(), + expected: SessionManager().requireNextcloud().username, actual: credentials.loginName, )) { _timer?.cancel(); @@ -120,7 +120,7 @@ class _NextcloudLoginFlowPageState extends State } switch (_step) { case _FlowStep.primary: - await AccountData().setLoginFlow(credentials.appPassword); + await SessionManager().setLoginFlow(credentials.appPassword); if (!mounted) return; // Zweite Freigabe direkt anstoßen: die Browser-Session besteht // bereits, es fehlt nur noch der Grant-Tipp. @@ -129,7 +129,7 @@ class _NextcloudLoginFlowPageState extends State case _FlowStep.talk: _finished = true; _timer?.cancel(); - await AccountData().setAppPasswordTalk(credentials.appPassword); + await SessionManager().setAppPasswordTalk(credentials.appPassword); if (!mounted) return; Navigator.of(context).pop(true); } diff --git a/lib/view/pages/files/widgets/file_leading.dart b/lib/view/pages/files/widgets/file_leading.dart index 3053ac4..8eb4fb2 100644 --- a/lib/view/pages/files/widgets/file_leading.dart +++ b/lib/view/pages/files/widgets/file_leading.dart @@ -2,8 +2,8 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; -import '../../../../model/account_data.dart'; import '../../../../model/endpoint_data.dart'; +import '../../../../session/session_manager.dart'; import '../data/file_type_icon.dart'; /// Leading slot for a file row: shows the Nextcloud thumbnail when the @@ -35,7 +35,7 @@ class FileLeading extends StatelessWidget { 'https://${EndpointData().nextcloud().full()}' '/index.php/core/preview' '?fileId=$fileId&x=128&y=128&a=0', - httpHeaders: AccountData().authHeaders(), + httpHeaders: SessionManager().requireNextcloud().authHeaders, fit: BoxFit.cover, fadeInDuration: Duration.zero, fadeOutDuration: Duration.zero, diff --git a/lib/view/pages/foreign_timetable/element_picker_page.dart b/lib/view/pages/foreign_timetable/element_picker_page.dart index 1d226de..d9ee475 100644 --- a/lib/view/pages/foreign_timetable/element_picker_page.dart +++ b/lib/view/pages/foreign_timetable/element_picker_page.dart @@ -7,7 +7,7 @@ import '../../../api/marianumconnect/queries/timetable_get_element_week/timetabl import '../../../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms.dart'; import '../../../api/marianumconnect/queries/timetable_get_students/timetable_get_students.dart'; import '../../../api/marianumconnect/queries/timetable_get_teachers/timetable_get_teachers.dart'; -import '../../../model/account_data.dart'; +import '../../../session/session_manager.dart'; import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../storage/timetable_favorites_settings.dart'; import '../../../utils/haptics.dart'; @@ -56,7 +56,7 @@ class _ElementPickerPageState extends State { } Future> _loadFor(TimetableElementType type) { - final epoch = AccountData().sessionEpoch; + final epoch = SessionManager().sessionEpoch; final cached = _futures[type]; if (cached != null && cached.epoch == epoch && diff --git a/lib/view/pages/settings/diagnostics_page.dart b/lib/view/pages/settings/diagnostics_page.dart index f11bdb6..f67959f 100644 --- a/lib/view/pages/settings/diagnostics_page.dart +++ b/lib/view/pages/settings/diagnostics_page.dart @@ -13,10 +13,11 @@ import '../../../api/marianumconnect/marianumconnect_endpoint.dart'; import '../../../api/marianumconnect/queries/telemetry_heartbeat/telemetry_device_id.dart'; import '../../../background/widget_background_task.dart'; import '../../../extensions/date_time.dart'; -import '../../../model/account_data.dart'; import '../../../model/endpoint_data.dart'; import '../../../push/push_registration_store.dart'; import '../../../push/push_registration_type.dart'; +import '../../../session/session.dart'; +import '../../../session/session_manager.dart'; import '../../../state/app/modules/account/bloc/account_bloc.dart'; import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../utils/clipboard_helper.dart'; @@ -44,7 +45,8 @@ class _DiagnosticsPageState extends State { Future> _collect() async { final settings = context.read().val(); final accountStatus = context.read().state.status; - final account = AccountData(); + final session = SessionManager().current; + final nextcloud = session?.nextcloud; const tokens = MarianumConnectTokenStorage(); final push = PushRegistrationStore(); @@ -101,13 +103,17 @@ class _DiagnosticsPageState extends State { title: 'Sitzung', rows: await rows({ 'Status': () async => accountStatus.name, - 'Benutzer': () async => - account.isPopulated() ? account.getUsername() : null, - 'Demo-Modus': () async => account.isDemo, - 'Login Flow v2': () async => account.usesLoginFlow, - 'App-Passwort (Dateien)': () async => account.hasAppPassword(), - 'App-Passwort (Talk)': () async => account.hasAppPasswordTalk(), - 'Session-Epoche': () async => account.sessionEpoch, + 'Benutzer': () async => switch (session) { + CredentialSession(:final username) => username, + null => null, + }, + 'Demo-Modus': () async => session?.isDemo ?? false, + 'Login Flow v2': () async => nextcloud?.usesLoginFlow ?? false, + 'App-Passwort (Dateien)': () async => + nextcloud?.hasAppPassword ?? false, + 'App-Passwort (Talk)': () async => + nextcloud?.hasAppPasswordTalk ?? false, + 'Session-Epoche': () async => SessionManager().sessionEpoch, 'MC-Token-ID': tokens.readTokenId, 'MC-Token läuft ab': tokens.readExpiresAt, }), diff --git a/lib/view/pages/settings/sections/account_section.dart b/lib/view/pages/settings/sections/account_section.dart index 5b82b30..cfb9d8b 100644 --- a/lib/view/pages/settings/sections/account_section.dart +++ b/lib/view/pages/settings/sections/account_section.dart @@ -4,10 +4,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../api/marianumcloud/cloud_users/cloud_users_actions.dart'; -import '../../../../api/marianumconnect/queries/auth_logout/auth_logout.dart'; -import '../../../../model/account_data.dart'; import '../../../../push/push_registration.dart'; import '../../../../routing/app_routes.dart'; +import '../../../../session/session_lifecycle.dart'; +import '../../../../session/session_manager.dart'; import '../../../../state/app/modules/account/bloc/account_bloc.dart'; import '../../../../state/app/modules/account/bloc/account_state.dart'; import '../../../../widget/app_progress_indicator.dart'; @@ -84,13 +84,17 @@ class _AccountSectionState extends State { setState(() => _avatarBusy = false); if (!ok) return; - invalidateAvatarCache(id: AccountData().getUsername(), isGroup: false); + invalidateAvatarCache( + id: SessionManager().requireNextcloud().username, + isGroup: false, + ); setState(() => _avatarVersion++); } @override Widget build(BuildContext context) { - final username = AccountData().getUsername(); + final nextcloud = SessionManager().requireNextcloud(); + final username = nextcloud.username; final displayName = _displayName; final theme = Theme.of(context); @@ -175,7 +179,7 @@ class _AccountSectionState extends State { // Nur für Login-Flow-Konten (2FA) sichtbar — Passwort-Konten heilen // sich still über das App-Passwort-Minting und sollen von dem ganzen // Flow-Mechanismus nichts mitbekommen. - if (!AccountData().isDemo && AccountData().usesLoginFlow) + if (!SessionManager().isDemo && nextcloud.usesLoginFlow) AsyncListTile( leading: const Icon(Icons.cloud_sync_outlined), title: const Text('Nextcloud neu verbinden'), @@ -203,7 +207,7 @@ class _AccountSectionState extends State { Future _showLogoutDialog(BuildContext context) async { // Flip AccountBloc state only after the dialog fully closes: doing it from - // inside removeData (the previous approach) raced AsyncDialogAction's + // inside the sign-out (the previous approach) raced AsyncDialogAction's // pop(true) against the listener's popUntil(isFirst) and could leave the // navigator in an inconsistent state. final confirmed = await showDialog( @@ -219,14 +223,8 @@ class _AccountSectionState extends State { context.read().setStatus(AccountStatus.loggedOut); } - // Ordered teardown: unregister push at Nextcloud + proxy and revoke the app - // password (while Nextcloud credentials are still available), THEN revoke the - // MC bearer token, and finally wipe local credentials. Each step is - // best-effort so an offline logout still reaches a clean local state. Future _performLogout() async { - await PushRegistration().logoutCleanup(); - await AuthLogout().run(); - await AccountData().removeData(); + await SessionLifecycle.signOut(); _cachedDisplayName = null; } } diff --git a/lib/view/pages/talk/data/chat_message.dart b/lib/view/pages/talk/data/chat_message.dart index 0f6de2e..ae69133 100644 --- a/lib/view/pages/talk/data/chat_message.dart +++ b/lib/view/pages/talk/data/chat_message.dart @@ -3,8 +3,8 @@ import 'package:flutter/material.dart'; import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart'; -import '../../../../model/account_data.dart'; import '../../../../model/endpoint_data.dart'; +import '../../../../session/session_manager.dart'; import '../../../../utils/emoji_detection.dart'; import '../../../../utils/url_opener.dart'; import '../widgets/highlighted_linkify.dart'; @@ -105,7 +105,7 @@ class ChatMessage { fadeInDuration: Duration.zero, fadeOutDuration: Duration.zero, errorListener: (value) {}, - httpHeaders: AccountData().authHeaders(), + httpHeaders: SessionManager().requireNextcloud().authHeaders, imageUrl: 'https://${EndpointData().nextcloud().full()}/index.php/core/preview?fileId=${file!.id}&x=130&y=-1&a=1', ), diff --git a/lib/view/pages/talk/details/message_reactions.dart b/lib/view/pages/talk/details/message_reactions.dart index b5d036c..24ace16 100644 --- a/lib/view/pages/talk/details/message_reactions.dart +++ b/lib/view/pages/talk/details/message_reactions.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import '../../../../api/marianumcloud/talk/get_reactions/get_reactions.dart'; import '../../../../api/marianumcloud/talk/get_reactions/get_reactions_response.dart'; -import '../../../../model/account_data.dart'; +import '../../../../session/session_manager.dart'; import '../../../../widget/centered_leading.dart'; import '../../../../widget/emoji_text.dart'; import '../../../../widget/loading_spinner.dart'; @@ -63,10 +63,10 @@ class _MessageReactionsState extends State { leading: CenteredLeading(EmojiText(entry.key)), title: Text('${entry.value.length} mal reagiert'), children: entry.value.map((e) { - final isSelf = AccountData().getUsername() == e.actorId; + final isSelf = + SessionManager().requireNextcloud().username == e.actorId; final isGuest = - e.actorType == - GetReactionsResponseObjectActorType.guests; + e.actorType == GetReactionsResponseObjectActorType.guests; return ListTile( leading: UserAvatar(id: e.actorId, isGroup: false), title: Text(e.actorDisplayName), diff --git a/lib/view/pages/talk/details/participants_list_view.dart b/lib/view/pages/talk/details/participants_list_view.dart index 3397b0e..25324df 100644 --- a/lib/view/pages/talk/details/participants_list_view.dart +++ b/lib/view/pages/talk/details/participants_list_view.dart @@ -2,7 +2,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import '../../../../api/marianumcloud/talk/get_participants/get_participants_response.dart'; -import '../../../../model/account_data.dart'; +import '../../../../session/session_manager.dart'; import '../../../../widget/user_avatar.dart'; import '../data/open_direct_chat.dart'; @@ -36,7 +36,7 @@ class ParticipantsListView extends StatelessWidget { (participant) => participant.participantType, ); - final selfId = AccountData().getUsername(); + final selfId = SessionManager().requireNextcloud().username; return Scaffold( appBar: AppBar(title: const Text('Mitglieder')), body: ListView( diff --git a/lib/view/pages/talk/details/shared_items_view.dart b/lib/view/pages/talk/details/shared_items_view.dart index 191f243..f288b4a 100644 --- a/lib/view/pages/talk/details/shared_items_view.dart +++ b/lib/view/pages/talk/details/shared_items_view.dart @@ -9,8 +9,8 @@ import '../../../../api/marianumcloud/talk/get_shared_items/get_shared_items_ove import '../../../../api/marianumcloud/talk/get_shared_items/get_shared_items_response.dart'; import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../../extensions/date_time.dart'; -import '../../../../model/account_data.dart'; import '../../../../model/endpoint_data.dart'; +import '../../../../session/session_manager.dart'; import '../../../../share_intent/remote_file_ref.dart'; import '../../../../utils/downloads/download_job.dart'; import '../../../../widget/app_progress_indicator.dart'; @@ -54,9 +54,10 @@ class SharedItemsPage { const SharedItemsPage(this.items, this.lastKnownMessageId, this.hasMore); } -List _fileItems(List items) => items - .where((item) => item.messageParameters?['file']?.path != null) - .toList(); +List _fileItems(List items) => + items + .where((item) => item.messageParameters?['file']?.path != null) + .toList(); SharedItemsPage buildSharedItemsPage( GetSharedItemsResponse response, @@ -140,7 +141,9 @@ class _SharedItemsViewState extends State Future _load() async { setState(() => _error = null); try { - final overview = await SharedItemsView.prefetchOverview(widget.room.token); + final overview = await SharedItemsView.prefetchOverview( + widget.room.token, + ); if (!mounted) return; _overview = overview; _prepareTabs(); @@ -501,7 +504,10 @@ class _SharedItemTileState extends State<_SharedItemTile> if (isDownloading) { confirmCancelDownload(); } else { - startDownload(name: _file.name, remoteFile: RemoteFileRef.fromTalk(_file)); + startDownload( + name: _file.name, + remoteFile: RemoteFileRef.fromTalk(_file), + ); } } @@ -533,7 +539,7 @@ class _SharedItemTileState extends State<_SharedItemTile> children: [ CachedNetworkImage( imageUrl: _previewUrl, - httpHeaders: AccountData().authHeaders(), + httpHeaders: SessionManager().requireNextcloud().authHeaders, fit: BoxFit.cover, fadeInDuration: Duration.zero, fadeOutDuration: Duration.zero, diff --git a/lib/view/pages/talk/widgets/chat_tile.dart b/lib/view/pages/talk/widgets/chat_tile.dart index 69cf23e..1aac3f5 100644 --- a/lib/view/pages/talk/widgets/chat_tile.dart +++ b/lib/view/pages/talk/widgets/chat_tile.dart @@ -9,9 +9,9 @@ import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../../api/marianumcloud/talk/room/notification_level.dart'; import '../../../../api/marianumcloud/talk/set_read_marker/set_read_marker.dart'; import '../../../../extensions/date_time.dart'; -import '../../../../model/account_data.dart'; import '../../../../notification/notification_tasks.dart'; import '../../../../routing/app_routes.dart'; +import '../../../../session/session_manager.dart'; import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import '../../../../utils/haptics.dart'; @@ -53,13 +53,9 @@ class _ChatTileState extends State { @override void initState() { super.initState(); - AccountData().waitForPopulation().then((_) { + SessionManager().waitForLoad().then((session) { if (!mounted) return; - setState( - () => selfUsername = AccountData().isPopulated() - ? AccountData().getUsername() - : null, - ); + setState(() => selfUsername = session?.nextcloud?.username); }); } diff --git a/lib/view/pages/talk/widgets/poll_options_list.dart b/lib/view/pages/talk/widgets/poll_options_list.dart index fab6e68..593cb38 100644 --- a/lib/view/pages/talk/widgets/poll_options_list.dart +++ b/lib/view/pages/talk/widgets/poll_options_list.dart @@ -5,7 +5,7 @@ import '../../../../api/marianumcloud/talk/get_poll/get_poll_state_response.dart import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../../api/marianumcloud/talk/vote_poll/vote_poll.dart'; import '../../../../api/marianumcloud/talk/vote_poll/vote_poll_params.dart'; -import '../../../../model/account_data.dart'; +import '../../../../session/session_manager.dart'; import '../../../../widget/async_action_button.dart'; import '../../../../widget/confirm_dialog.dart'; import '../../../../widget/demo_restricted.dart'; @@ -186,7 +186,7 @@ class _PollOptionsListState extends State { Widget _actionBar(GetPollStateResponseObject poll, ThemeData theme) { final canClose = poll.canClose( - selfId: AccountData().getUsername(), + selfId: SessionManager().requireNextcloud().username, participantType: widget.room.participantType, ); if (!_isInteractive && !canClose) return const SizedBox.shrink(); diff --git a/lib/widget/file_viewer/unknown_preview_block.dart b/lib/widget/file_viewer/unknown_preview_block.dart index af297de..5386464 100644 --- a/lib/widget/file_viewer/unknown_preview_block.dart +++ b/lib/widget/file_viewer/unknown_preview_block.dart @@ -1,8 +1,8 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; -import '../../model/account_data.dart'; import '../../model/endpoint_data.dart'; +import '../../session/session_manager.dart'; import '../../share_intent/remote_file_ref.dart'; import '../app_progress_indicator.dart'; @@ -67,7 +67,7 @@ class _UnknownPreviewBlockState extends State { width: _previewSize, height: _previewSize, child: CachedNetworkImage( - httpHeaders: AccountData().authHeaders(), + httpHeaders: SessionManager().requireNextcloud().authHeaders, imageUrl: _ncPreviewUrl(remote, width: 360), fadeInDuration: Duration.zero, fadeOutDuration: Duration.zero, diff --git a/lib/widget/user_avatar.dart b/lib/widget/user_avatar.dart index 5556d6c..f9f0f29 100644 --- a/lib/widget/user_avatar.dart +++ b/lib/widget/user_avatar.dart @@ -8,9 +8,9 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:http/http.dart' as http; import '../api/http_errors.dart'; -import '../model/account_data.dart'; import '../model/endpoint_data.dart'; import '../push/push_avatar.dart'; +import '../session/session_manager.dart'; import 'a11y/a11y_labels.dart'; import 'avatar_disk_cache.dart'; @@ -162,7 +162,9 @@ Future _fetchAvatarPayload(String url) async { () => http.get( Uri.parse(url), headers: { - ...AccountData().authHeaders(), + // User avatars are public in Nextcloud, so they also load while the + // active session has no Nextcloud identity. + ...?SessionManager().current?.nextcloud?.authHeaders, 'Accept': 'image/png,image/jpeg,image/webp,image/svg+xml', }, ), @@ -368,7 +370,6 @@ class _UserAvatarState extends State { return listEquals(a.bytes, b.bytes); } - @override Widget build(BuildContext context) { final radius = widget.size.toDouble(); diff --git a/lib/widget_data/widget_publisher.dart b/lib/widget_data/widget_publisher.dart index d087234..6dd8f39 100644 --- a/lib/widget_data/widget_publisher.dart +++ b/lib/widget_data/widget_publisher.dart @@ -4,7 +4,7 @@ import 'dart:developer'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import '../model/account_data.dart'; +import '../session/session_manager.dart'; import '../state/app/modules/timetable/bloc/timetable_state.dart'; import '../storage/settings.dart'; import 'widget_data_mapper.dart'; @@ -48,7 +48,7 @@ class WidgetPublisher { bool isTeacher = false, int? epoch, }) { - final sessionEpoch = epoch ?? AccountData().sessionEpoch; + final sessionEpoch = epoch ?? SessionManager().sessionEpoch; return _queue = _queue.then( (_) => _publish( state, @@ -65,7 +65,7 @@ class WidgetPublisher { required bool isTeacher, required int epoch, }) async { - if (!AccountData().isCurrentSession(epoch)) return; + if (!SessionManager().isCurrentSession(epoch)) return; try { final connectDouble = settings?.timetableSettings.connectDoubleLessons ?? true; @@ -106,7 +106,7 @@ class WidgetPublisher { ); // A publish still running at sign-out would put the previous account's // plan back onto the just cleared widget. - if (!AccountData().isCurrentSession(epoch)) return; + if (!SessionManager().isCurrentSession(epoch)) return; // Most bloc emits (week swipes, prefetches) don't touch the widget's // window; skip the SharedPreferences commits and widget re-render then. final signature = jsonEncode([ diff --git a/test/session/session_codec_test.dart b/test/session/session_codec_test.dart new file mode 100644 index 0000000..6846ff6 --- /dev/null +++ b/test/session/session_codec_test.dart @@ -0,0 +1,110 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/session/nextcloud_credentials.dart'; +import 'package:marianum_mobile/session/session.dart'; +import 'package:marianum_mobile/session/session_codec.dart'; + +String _basic(String user, String secret) => + 'Basic ${base64Encode(utf8.encode('$user:$secret'))}'; + +void main() { + group('decodeSession – installs from before account kinds', () { + test('username + password without a kind is a credential session', () { + final session = decodeSession({ + 'username': 'max', + 'password': 'pw', + 'nextcloud_app_password': 'app', + 'nextcloud_app_password_talk': 'talk', + }); + expect(session, isA()); + final credential = session! as CredentialSession; + expect(credential.username, 'max'); + expect(credential.password, 'pw'); + expect(credential.isDemo, isFalse); + expect(credential.nextcloud.appPassword, 'app'); + expect(credential.nextcloud.appPasswordTalk, 'talk'); + expect(credential.nextcloud.usesLoginFlow, isFalse); + }); + + test('login-flow and demo flags are carried over', () { + final session = + decodeSession({ + 'username': 'demo@x', + 'password': 'demo', + 'is_demo': 'true', + 'nextcloud_login_flow': 'true', + })! + as CredentialSession; + expect(session.isDemo, isTrue); + expect(session.nextcloud.usesLoginFlow, isTrue); + }); + + test('missing password means signed out', () { + expect(decodeSession({'username': 'max'}), isNull); + expect(decodeSession({}), isNull); + }); + }); + + group('decodeSession – kinds', () { + test('an unknown kind from a newer app version reads as signed out', () { + expect( + decodeSession({ + 'session_kind': 'something-new', + 'username': 'max', + 'password': 'pw', + }), + isNull, + ); + }); + }); + + group('encodeSessionFields', () { + test('round-trips a credential session', () { + final original = CredentialSession( + username: 'max', + password: 'pw', + usesLoginFlow: true, + ); + final decoded = + decodeSession(encodeSessionFields(original))! as CredentialSession; + expect(decoded.username, 'max'); + expect(decoded.password, 'pw'); + expect(decoded.nextcloud.usesLoginFlow, isTrue); + }); + }); + + group('NextcloudCredentials', () { + const base = NextcloudCredentials(username: 'max', password: 'pw'); + + test('prefers the app password once available', () { + expect(base.basicAuthHeader, _basic('max', 'pw')); + final withApp = base.copyWith(appPassword: () => 'app'); + expect(withApp.basicAuthHeader, _basic('max', 'app')); + expect(withApp.secret, 'app'); + expect(withApp.realPasswordBasicAuthHeader, _basic('max', 'pw')); + }); + + test('talk header needs its own app password', () { + expect(() => base.talkBasicAuthHeader, throwsStateError); + final withTalk = base.copyWith(appPasswordTalk: () => 't'); + expect(withTalk.talkBasicAuthHeader, _basic('max', 't')); + }); + + test('login-flow accounts share the flow password for talk', () { + final flow = base.copyWith( + appPassword: () => 'flow', + usesLoginFlow: true, + ); + expect(flow.talkBasicAuthHeader, _basic('max', 'flow')); + }); + + test('copyWith can clear an app password', () { + final cleared = base + .copyWith(appPassword: () => 'app') + .copyWith(appPassword: () => null); + expect(cleared.hasAppPassword, isFalse); + expect(cleared.secret, 'pw'); + }); + }); +} From 27563a6dc10e5bdc4c7fd0e00178ee7f367e779f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Sun, 27 Sep 2026 14:50:35 +0200 Subject: [PATCH 16/18] added support for multiple accounts with an account switcher in settings --- CLAUDE.md | 4 +- lib/api/cache_store.dart | 99 +++-- .../app_password/delete_app_password.dart | 13 +- .../auth/auth_interceptor.dart | 11 + .../auth/session_validator.dart | 11 +- .../marianumconnect/auth/token_storage.dart | 20 + .../queries/auth_logout/auth_logout.dart | 15 + .../queries/auth_me/auth_me.dart | 40 ++ lib/main.dart | 417 +++++++++--------- lib/model/session_wipe.dart | 111 ++--- lib/push/push_registration.dart | 7 + lib/routing/app_routes.dart | 4 +- lib/session/account_codec.dart | 218 +++++++++ lib/session/session_lifecycle.dart | 186 +++++++- lib/session/session_manager.dart | 156 ++++++- .../loadable_hydrated_bloc.dart | 10 + .../app/modules/account/account_scope.dart | 66 +++ .../modules/account/bloc/account_bloc.dart | 32 +- .../modules/account/bloc/account_event.dart | 6 + .../modules/account/bloc/account_state.dart | 17 +- .../chat_list/bloc/chat_list_bloc.dart | 6 +- .../modules/settings/bloc/settings_cubit.dart | 9 +- lib/storage/account_storage.dart | 78 ++++ lib/storage/hydrated_storage_bootstrap.dart | 28 +- lib/view/login/login.dart | 137 ++++-- lib/view/login/widgets/login_branding.dart | 10 +- lib/view/login/widgets/login_card.dart | 13 +- lib/view/pages/settings/diagnostics_page.dart | 9 +- .../settings/sections/account_section.dart | 123 +++--- lib/widget/account_switcher_sheet.dart | 189 ++++++++ lib/widget/debug/bloc_storage_view.dart | 36 +- test/api/cache_store_test.dart | 16 + test/session/account_codec_test.dart | 166 +++++++ 33 files changed, 1801 insertions(+), 462 deletions(-) create mode 100644 lib/api/marianumconnect/queries/auth_me/auth_me.dart create mode 100644 lib/session/account_codec.dart create mode 100644 lib/state/app/modules/account/account_scope.dart create mode 100644 lib/storage/account_storage.dart create mode 100644 lib/widget/account_switcher_sheet.dart create mode 100644 test/session/account_codec_test.dart diff --git a/CLAUDE.md b/CLAUDE.md index 1777378..9e4ba53 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ Flutter-App für die Schul-Community: Webuntis-Stundenplan, Nextcloud Talk + Fil ``` lib/ ├── api/ HTTP-Layer pro Backend (mhsl/, marianumcloud/, webuntis/, holidays/) -├── session/ Session-Modell, SessionManager, SessionLifecycle +├── session/ Session-Modell, SessionManager, SessionLifecycle, Konten-Index ├── state/app/modules/ BLoC pro Feature-Modul (timetable, chat, chat_list, files, ...) ├── state/app/infrastructure LoadableState, DataLoader, geteilte BLoC-Bausteine ├── view/ Screens @@ -54,6 +54,8 @@ lib/ **Session:** Die aktive Sitzung liegt in `SessionManager().current` (`lib/session/`). Nextcloud-Zugriffe nur über `SessionManager().requireNextcloud()`. Abmelden ausschließlich über `SessionLifecycle.signOut()`. Die Keychain-Keys in `SessionKeys` sind eingefroren (Bestandsinstallationen, iOS-NSE). +**Mehrere Konten:** Die `SessionKeys`-Slots, der MC-Token und die Group-Keychain spiegeln immer das *aktive* Konto; inaktive Konten liegen im Tresor (`accounts_index` + `account_vault_`, `lib/session/account_codec.dart`). Wechseln/Hinzufügen/Entfernen nur über `SessionLifecycle` (`switchTo`, `beginAddAccount`/`finishLogin`/`cancelAddAccount`, `removeInactive`), danach `AccountBloc.activated(id)`. Lokale Daten sind pro Konto getrennt (`AccountStorage`: eigene HydratedBloc-Box; der Request-Cache `CacheStore` folgt dem aktiven Konto mit eigenem Verzeichnis); nur die Settings liegen in der globalen Box. `SessionManager().sessionEpoch` steigt bei jedem Kontowechsel – asynchrone Arbeit prüft damit, ob ihr Ergebnis noch zum aktiven Konto gehört. Was ein Konto außerhalb seines Speichers hinterlässt (Tray, Push-Schlüssel, Downloads), räumt `SessionWipe` auf. Alle Pro-Konto-Blocs gehören in `AccountScope` – er wird per Konto-ID neu aufgebaut (samt `MaterialApp`/Navigator). Push ist nur für das aktive Konto registriert. + ## Build / Run ```bash diff --git a/lib/api/cache_store.dart b/lib/api/cache_store.dart index 81412f3..169540c 100644 --- a/lib/api/cache_store.dart +++ b/lib/api/cache_store.dart @@ -5,6 +5,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:path_provider/path_provider.dart'; +import '../session/session_manager.dart'; import '../utils/directory_size.dart'; /// One cached response: the raw JSON payload and when it was stored. @@ -22,6 +23,9 @@ class CacheEntry { /// in memory for the whole session and JSON-encodes payloads a second time. /// Here every access is async and touches exactly one file, and the payload is /// stored verbatim behind a one-line header (`\n`). +/// +/// Every account has its own directory; all access goes to the one of the +/// active account, in every isolate. class CacheStore { CacheStore._(); @@ -30,15 +34,30 @@ class CacheStore { /// Payloads above this size are decoded on a background isolate. static const int isolateDecodeThreshold = 64 * 1024; - Future? _dir; + static const _directoryPrefix = 'request_cache'; + + final Map> _dirs = {}; final Map> _writes = {}; - Future _directory() => _dir ??= () async { - final base = await getApplicationCacheDirectory(); - final dir = Directory('${base.path}/request_cache'); - await dir.create(recursive: true); - return dir; - }(); + /// Directory name for an account's storage [namespace]; null while no + /// account is active. The account from before multi-account support + /// (namespace '') keeps the original directory. + @visibleForTesting + static String directoryName(String? namespace) => switch (namespace) { + null => '$_directoryPrefix-signed-out', + '' => _directoryPrefix, + _ => '$_directoryPrefix-$namespace', + }; + + Future _directory() { + final name = directoryName(SessionManager().activeAccount?.namespace); + return _dirs[name] ??= () async { + final base = await getApplicationCacheDirectory(); + final dir = Directory('${base.path}/$name'); + await dir.create(recursive: true); + return dir; + }(); + } Future _file(String key) async => File('${(await _directory()).path}/${_safeName(key)}'); @@ -49,8 +68,9 @@ class CacheStore { Future read(String key) async { try { - await _writes[key]; - return parse(await (await _file(key)).readAsString()); + final file = await _file(key); + await _writes[file.path]; + return parse(await file.readAsString()); } on Object { // Missing (PathNotFoundException) or unreadable: a cache miss. return null; @@ -64,18 +84,21 @@ class CacheStore { return {for (var i = 0; i < keys.length; i++) keys[i]: ?entries[i]}; } - Future write(String key, String json) { - final previous = _writes[key] ?? Future.value(); + Future write(String key, String json) async { + // Resolved up front: a queued write must not follow an account switch + // into the next account's directory. + final file = await _file(key); + final path = file.path; + final previous = _writes[path] ?? Future.value(); late final Future current; - current = previous.then((_) => _write(key, json)).whenComplete(() { - if (identical(_writes[key], current)) _writes.remove(key); + current = previous.then((_) => _write(file, json)).whenComplete(() { + if (identical(_writes[path], current)) _writes.remove(path); }); - return _writes[key] = current; + return _writes[path] = current; } - Future _write(String key, String json) async { + Future _write(File file, String json) async { try { - final file = await _file(key); // Write-then-rename so a reader (or the widget background isolate) // never sees a half-written file. final tmp = File('${file.path}.tmp'); @@ -85,19 +108,21 @@ class CacheStore { ); await tmp.rename(file.path); } on Object catch (e) { - debugPrint('CacheStore.write($key) failed: $e'); + debugPrint('CacheStore.write(${file.path}) failed: $e'); } } Future delete(String key) async { try { - await _writes[key]; - await (await _file(key)).delete(); + final file = await _file(key); + await _writes[file.path]; + await file.delete(); } on Object { // A missing or locked file is as good as deleted for a cache. } } + /// Empties the active account's cache. Future clear() async { try { final dir = await _directory(); @@ -105,7 +130,20 @@ class CacheStore { } on Object catch (e) { debugPrint('CacheStore.clear failed: $e'); } - _dir = null; + _dirs.clear(); + } + + /// Removes the cache of the account with [namespace], active or not. + Future deleteNamespace(String namespace) async { + final name = directoryName(namespace); + try { + final base = await getApplicationCacheDirectory(); + final dir = Directory('${base.path}/$name'); + if (dir.existsSync()) await dir.delete(recursive: true); + } on Object catch (e) { + debugPrint('CacheStore.deleteNamespace failed: $e'); + } + _dirs.removeWhere((key, _) => key == name); } /// All stored keys, optionally filtered by [prefix]. @@ -124,17 +162,22 @@ class CacheStore { } } - /// Removes entries not written for [maxAge], judged by file mtime so nothing - /// has to be read or decoded. + /// Removes entries not written for [maxAge] from every account's cache, + /// judged by file mtime so nothing has to be read or decoded. Future deleteOlderThan(Duration maxAge) async { try { - final dir = await _directory(); + final base = await getApplicationCacheDirectory(); final cutoff = DateTime.now().subtract(maxAge); - await for (final entity in dir.list()) { - if (entity is! File) continue; - // ignore: avoid_slow_async_io - final modified = (await entity.stat()).modified; - if (modified.isBefore(cutoff)) await entity.delete(); + await for (final dir in base.list()) { + if (dir is! Directory) continue; + final name = dir.uri.pathSegments.lastWhere((s) => s.isNotEmpty); + if (!name.startsWith(_directoryPrefix)) continue; + await for (final entity in dir.list()) { + if (entity is! File) continue; + // ignore: avoid_slow_async_io + final modified = (await entity.stat()).modified; + if (modified.isBefore(cutoff)) await entity.delete(); + } } } on Object catch (e) { debugPrint('CacheStore.deleteOlderThan failed: $e'); diff --git a/lib/api/marianumcloud/app_password/delete_app_password.dart b/lib/api/marianumcloud/app_password/delete_app_password.dart index 12cbcd9..3726c86 100644 --- a/lib/api/marianumcloud/app_password/delete_app_password.dart +++ b/lib/api/marianumcloud/app_password/delete_app_password.dart @@ -16,10 +16,15 @@ class DeleteAppPassword { Future run({String? authorizationHeader}) async { await _client.delete( NextcloudOcs.uri('core/apppassword'), - headers: { - ...NextcloudOcs.headers(), - 'Authorization': ?authorizationHeader, - }, + // An explicit header may belong to an inactive account, so the active + // session's headers must not be required then. + headers: authorizationHeader == null + ? NextcloudOcs.headers() + : { + 'Accept': 'application/json', + 'OCS-APIRequest': 'true', + 'Authorization': authorizationHeader, + }, ); } } diff --git a/lib/api/marianumconnect/auth/auth_interceptor.dart b/lib/api/marianumconnect/auth/auth_interceptor.dart index 3118911..ed0a1d8 100644 --- a/lib/api/marianumconnect/auth/auth_interceptor.dart +++ b/lib/api/marianumconnect/auth/auth_interceptor.dart @@ -19,6 +19,15 @@ class MarianumConnectAuthInterceptor extends Interceptor { // each spawning a fresh row in api_tokens. Future? _pendingReLogin; + static Future? _anyPendingReLogin; + + /// Resolves once no silent re-login is running. An account switch waits for + /// it — the renewed token would otherwise land in the next account's slot. + static Future idle() async { + final pending = _anyPendingReLogin; + if (pending != null) await pending; + } + MarianumConnectAuthInterceptor({ MarianumConnectTokenStorage tokenStorage = const MarianumConnectTokenStorage(), @@ -81,8 +90,10 @@ class MarianumConnectAuthInterceptor extends Interceptor { if (inFlight != null) return inFlight; final fresh = _performReLogin(); _pendingReLogin = fresh; + _anyPendingReLogin = fresh; fresh.whenComplete(() { if (identical(_pendingReLogin, fresh)) _pendingReLogin = null; + if (identical(_anyPendingReLogin, fresh)) _anyPendingReLogin = null; }); return fresh; } diff --git a/lib/api/marianumconnect/auth/session_validator.dart b/lib/api/marianumconnect/auth/session_validator.dart index 311db69..c5f67ee 100644 --- a/lib/api/marianumconnect/auth/session_validator.dart +++ b/lib/api/marianumconnect/auth/session_validator.dart @@ -8,9 +8,10 @@ import '../queries/auth_verify/auth_verify.dart'; /// Credential probe. A server-side password rotation forces a re-login on the /// next cold start even when the bearer token would still be accepted. +/// Another stored account then takes over. class SessionValidator { static Future probeStored({ - required Future Function() onInvalidated, + required Future Function(String? nextAccountId) onInvalidated, }) async { final session = SessionManager().current; // The probes use their own dio (bypassing the demo interceptor), so a demo @@ -24,18 +25,18 @@ class SessionValidator { } } on AuthException catch (e) { if (e.statusCode != 401) return; - // The probed account already signed out; the 401 must not sign out - // whoever logged in meanwhile. + // The probed account is no longer the active one; the 401 must not + // sign out whoever took over meanwhile. if (!SessionManager().isCurrentSession(epoch)) return; log('MC: stored session rejected — forcing re-login'); - await SessionLifecycle.signOut( + final next = await SessionLifecycle.signOut( notice: switch (session) { CredentialSession() => 'Deine Zugangsdaten wurden vom Server abgelehnt. Vermutlich ' 'wurde dein Passwort geändert. Bitte melde dich erneut an.', }, ); - await onInvalidated(); + await onInvalidated(next); } catch (e) { log('MC: background session check failed (transient): $e'); } diff --git a/lib/api/marianumconnect/auth/token_storage.dart b/lib/api/marianumconnect/auth/token_storage.dart index 24e85c8..4692dae 100644 --- a/lib/api/marianumconnect/auth/token_storage.dart +++ b/lib/api/marianumconnect/auth/token_storage.dart @@ -60,6 +60,26 @@ class MarianumConnectTokenStorage { ); } + static const bearerKey = _tokenKey; + static const fieldKeys = [_tokenKey, _tokenIdKey, _expiresAtKey]; + + /// Raw stored fields, for parking the token of an inactive account. + Future> readAll() async => { + for (final key in fieldKeys) key: ?await _storage.read(key: key), + }; + + /// Restores fields from [readAll]; missing ones are deleted. + Future writeAll(Map fields) async { + for (final key in fieldKeys) { + final value = fields[key]; + if (value == null) { + await _storage.delete(key: key); + } else { + await _storage.write(key: key, value: value); + } + } + } + Future clear() async { await _storage.delete(key: _tokenKey); await _storage.delete(key: _tokenIdKey); diff --git a/lib/api/marianumconnect/queries/auth_logout/auth_logout.dart b/lib/api/marianumconnect/queries/auth_logout/auth_logout.dart index e96d581..dad824a 100644 --- a/lib/api/marianumconnect/queries/auth_logout/auth_logout.dart +++ b/lib/api/marianumconnect/queries/auth_logout/auth_logout.dart @@ -1,6 +1,8 @@ import 'package:dio/dio.dart'; import '../../auth/token_storage.dart'; +import '../../marianumconnect_api.dart'; +import '../../marianumconnect_endpoint.dart'; import '../../marianumconnect_query.dart'; /// Revokes the stored MC bearer token both server-side and locally. Best-effort @@ -24,4 +26,17 @@ class AuthLogout extends MarianumConnectQuery { await _tokenStorage.clear(); } } + + /// Revokes the token of an inactive account; the stored (active) token is + /// left alone. Best-effort. + static Future revoke(String token) async { + try { + await MarianumConnectApi.plainDio().post( + MarianumConnectEndpoint.resolve('auth/logout'), + options: Options(headers: {'Authorization': 'Bearer $token'}), + ); + } on DioException catch (_) { + // ignore + } + } } diff --git a/lib/api/marianumconnect/queries/auth_me/auth_me.dart b/lib/api/marianumconnect/queries/auth_me/auth_me.dart new file mode 100644 index 0000000..76b597e --- /dev/null +++ b/lib/api/marianumconnect/queries/auth_me/auth_me.dart @@ -0,0 +1,40 @@ +import 'package:dio/dio.dart'; + +import '../../../errors/auth_exception.dart'; +import '../../auth/token_storage.dart'; +import '../../marianumconnect_api.dart'; +import '../../marianumconnect_query.dart'; +import '../auth_login/auth_login_response.dart'; + +/// Reads the user behind the stored bearer token, which also probes that the +/// token is still accepted. +/// +/// Bypasses the shared dio singleton so the auth interceptor does not react +/// to the 401 this probe is meant to observe. +class AuthMe extends MarianumConnectQuery { + final MarianumConnectTokenStorage _tokenStorage; + + AuthMe({ + MarianumConnectTokenStorage tokenStorage = + const MarianumConnectTokenStorage(), + Dio? dio, + }) : _tokenStorage = tokenStorage, + super(dio: dio ?? MarianumConnectApi.plainDio()); + + /// Throws [AuthException] when the token is missing or rejected. + Future run() async { + await user(); + } + + /// The signed-in user (names, type). Throws like [run]. + Future user() async { + final options = await _tokenStorage.requireBearerOptions('AuthMe'); + return guard(() async { + final response = await dio.get>( + endpoint('auth/me'), + options: options, + ); + return AuthLoginUser.fromJson(response.data!); + }); + } +} diff --git a/lib/main.dart b/lib/main.dart index 4d457c7..842c8d8 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,7 +11,6 @@ import 'package:flutter/scheduler.dart' show timeDilation; 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'; @@ -33,19 +32,20 @@ import 'push/push_registration.dart'; import 'push/push_registration_store.dart'; import 'push/push_renderer.dart'; import 'routing/app_routes.dart'; +import 'session/account_codec.dart'; +import 'session/session_lifecycle.dart'; import 'session/session_manager.dart'; import 'share_intent/share_intent_listener.dart'; +import 'state/app/modules/account/account_scope.dart'; import 'state/app/modules/account/bloc/account_bloc.dart'; import 'state/app/modules/account/bloc/account_state.dart'; import 'state/app/modules/app_modules.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/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart'; import 'state/app/modules/settings/bloc/settings_cubit.dart'; import 'state/app/modules/timetable/bloc/timetable_bloc.dart'; -import 'storage/hydrated_storage_bootstrap.dart'; +import 'storage/account_storage.dart'; import 'storage/settings.dart'; import 'theming/dark_app_theme.dart'; import 'theming/light_app_theme.dart'; @@ -59,6 +59,7 @@ import 'widget/breaker/breaker.dart'; import 'widget/debug/debug_stats_overlay.dart'; import 'widget/downloads/download_tray.dart'; import 'widget/emergency/emergency_notice_gate.dart'; +import 'widget/info_dialog.dart'; import 'widget_data/widget_sync.dart'; /// Runs one startup step with a time limit. Anything that throws or hangs @@ -146,8 +147,7 @@ Future main() async { ]), ), _startupStep('hydrated storage', () async { - final path = (await getTemporaryDirectory()).path; - HydratedBloc.storage = await buildHydratedStorageWithFallback(path); + await AccountStorage.init((await getTemporaryDirectory()).path); }, timeout: null), _startupStep('documents dir', () async { AppPaths.documentsDir = (await getApplicationDocumentsDirectory()).path; @@ -167,6 +167,16 @@ Future main() async { log('starting app initialisation...'); await Future.wait(initialisationTasks); + // Snapshot: the session may still finish loading later (see _MainState), + // the bloc and the storage must agree on the account they start with. + final initialAccount = SessionManager().isLoaded + ? SessionManager().activeAccount + : null; + await _startupStep( + 'account storage', + () => AccountStorage.activate(initialAccount), + timeout: null, + ); log('app initialisation done!'); // Independent of the notification setup below, so it runs alongside it. @@ -237,34 +247,48 @@ Future main() async { // placeholder flash. AvatarDiskCache.instance.warmUp(); + // Created eagerly so the endpoint is configured before anything below can + // issue a request (the timetable bloc loads on creation). + final settingsCubit = SettingsCubit(storage: AccountStorage.global); + _syncMarianumConnectEndpoint(settingsCubit.state); + settingsCubit.stream.listen(_syncMarianumConnectEndpoint); + log('running app...'); runApp( MultiBlocProvider( providers: [ - BlocProvider(create: (_) => SettingsCubit()), + BlocProvider.value(value: settingsCubit), BlocProvider( - create: (_) => AccountBloc(initialStatus: _initialAccountStatus()), + create: (_) => AccountBloc( + initialStatus: _initialAccountStatus(initialAccount), + accountId: initialAccount?.id, + ), ), - BlocProvider(create: (_) => BreakerBloc()), - BlocProvider(create: (_) => CapabilitiesCubit()), - BlocProvider( - create: (_) => NextcloudCapabilitiesCubit(), - ), - BlocProvider(create: (_) => ChatListBloc()), - BlocProvider( - create: (ctx) => ChatBloc(chatListBloc: ctx.read()), - ), - BlocProvider(create: (_) => TimetableBloc()), ], child: const Main(), ), ); } -AccountStatus _initialAccountStatus() { - final session = SessionManager(); - if (session.isSignedIn) return AccountStatus.loggedIn; - return session.isLoaded ? AccountStatus.loggedOut : AccountStatus.undefined; +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(AccountEntry? account) { + if (account != null) return AccountStatus.loggedIn; + return SessionManager().isLoaded + ? AccountStatus.loggedOut + : AccountStatus.undefined; } class Main extends StatefulWidget { @@ -293,31 +317,52 @@ class _MainState extends State
{ Jiffy.setLocale('de'); _lastStatus = context.read().state.status; - SessionManager().waitForLoad().then((session) { + SessionManager().waitForLoad().then((session) async { 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 — refresh - // capabilities here, then self-heal the push registration. - final settingsCubit = context.read(); - unawaited( - context.read().load().then((_) { - if (!mounted) return; - final capabilities = context.read(); - _syncPush(settingsCubit, capabilities); - _applyRoleDefaults(settingsCubit, capabilities); - }), - ); - unawaited(context.read().load()); + if (session == null) { + accountBloc.setStatus(AccountStatus.loggedOut); + return; } + // Covers a session that finished loading after the startup snapshot; + // otherwise the id is unchanged and nothing remounts. + final account = SessionManager().activeAccount; + await AccountStorage.activate(account); + if (!mounted) return; + accountBloc.activated(account?.id); + _scheduleSessionValidation(accountBloc); }); } + /// Runs whenever the account-scoped tree mounts for a signed-in account: + /// pulls the capability flags, then registers push for this account. + void _onSessionActive(BuildContext scopeContext) { + final settingsCubit = scopeContext.read(); + final capabilitiesCubit = scopeContext.read(); + unawaited( + capabilitiesCubit.load().then((_) { + // Closed: the account was switched away from while loading. + if (!mounted || capabilitiesCubit.isClosed) return; + _syncPush(settingsCubit, capabilitiesCubit); + _applyRoleDefaults(settingsCubit, capabilitiesCubit); + }), + ); + unawaited(scopeContext.read().load()); + unawaited(SessionLifecycle.refreshDisplayName()); + _prefetchBaseData(scopeContext); + WidgetsBinding.instance.addPostFrameCallback((_) => _showTakeoverNotice()); + } + + /// A forced sign-out that handed over to another account never reaches the + /// login screen, which normally explains it. + void _showTakeoverNotice() { + final notice = SessionLifecycle.signOutNotice.value; + final overlayContext = AppRoutes.overlayContext; + if (notice == null || overlayContext == null) return; + SessionLifecycle.signOutNotice.value = null; + InfoDialog.show(overlayContext, notice, title: 'Abgemeldet'); + } + /// Warms the core caches (timetable, chat list, files root) in the /// background so the first screen render hits populated data. void _prefetchBaseData(BuildContext context) { @@ -362,19 +407,48 @@ class _MainState extends State
{ } /// 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). + /// server-side, so the validator signs the account out and the app moves + /// on to another stored account or the login screen. void _scheduleSessionValidation(AccountBloc accountBloc) { unawaited( SessionValidator.probeStored( - onInvalidated: () async { + onInvalidated: (nextAccountId) async { if (!mounted) return; - accountBloc.setStatus(AccountStatus.loggedOut); + accountBloc.activated(nextAccountId); }, ), ); } + void _onAccountChanged(BuildContext context, AccountState accountState) { + if (accountState.status == AccountStatus.loggedIn && + accountState.freshLogin) { + _showPostLoginSplash = true; + _appMounted = false; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() => _appMounted = true); + }); + } + final wasLoggedIn = _lastStatus == AccountStatus.loggedIn; + _lastStatus = accountState.status; + // A cold start that merely resolves as signed out has nothing to wipe, + // and a share waiting for the login must survive it. + if (accountState.status != AccountStatus.loggedOut || !wasLoggedIn) return; + // Deferred until the account-scoped tree is torn down. + WidgetsBinding.instance.addPostFrameCallback( + (_) => unawaited(SessionWipe.deviceLeft()), + ); + } + + /// The account-scoped tree only lives while its account is the one in use: + /// adding another account parks it, so its blocs go as well. + static Object _scopeOf(AccountState state) => ( + state.accountId, + state.status == AccountStatus.addingAccount, + ); + + Object? _scope; + @override Widget build(BuildContext context) => Directionality( textDirection: TextDirection.ltr, @@ -384,7 +458,6 @@ class _MainState extends State
{ child: BlocSelector( selector: (settings) => ( appTheme: settings.appTheme, - mcBaseUrl: settings.devToolsSettings.resolveMarianumConnectBaseUrl(), notificationsEnabled: settings.notificationSettings.enabled, showPerformanceOverlay: settings.devToolsSettings.showPerformanceOverlay, @@ -395,12 +468,6 @@ class _MainState extends State
{ textScaleOverride: settings.devToolsSettings.textScaleOverride, ), builder: (context, root) { - // Keep the MC dio singleton aligned with the currently selected - // endpoint (live / beta / custom). Idempotent when the URL is - // unchanged. Mirrored into WidgetSync so the background isolate - // refreshes against the same endpoint. - MarianumConnectEndpoint.update(root.mcBaseUrl); - unawaited(WidgetSync.setMarianumConnectBaseUrl(root.mcBaseUrl)); // Mirror the notification toggle into group-scoped storage so the FCM // background isolate and the iOS NSE can suppress rendering when off. unawaited( @@ -409,164 +476,116 @@ class _MainState extends State
{ ), ); timeDilation = root.slowAnimations ? 5.0 : 1.0; - return MaterialApp( - showPerformanceOverlay: root.showPerformanceOverlay, - showSemanticsDebugger: root.showSemanticsDebugger, - 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: _navigatorObservers, - localizationsDelegates: const [ - ...GlobalMaterialLocalizations.delegates, - GlobalWidgetsLocalizations.delegate, - ], - supportedLocales: const [Locale('de'), Locale('en')], - locale: const Locale('de'), - title: 'Marianum Fulda', - themeMode: root.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) { - Widget app = 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()), - ); - final scale = root.textScaleOverride; - if (scale > 0) { - app = MediaQuery.withClampedTextScaling( - minScaleFactor: scale, - maxScaleFactor: scale, - child: app, - ); + return BlocConsumer( + listenWhen: (previous, current) => + previous.status != current.status || + previous.accountId != current.accountId, + listener: _onAccountChanged, + buildWhen: (previous, current) => + _scopeOf(previous) != _scopeOf(current), + builder: (context, account) { + final scope = _scopeOf(account); + if (scope != _scope) { + _scope = scope; + // The old navigator (and every route on it) goes with the old + // account; a shared GlobalKey would carry it over instead. + AppRoutes.rootNavigatorKey = GlobalKey(); } - return DebugStatsOverlay( - showFrameStats: root.showFrameStats, - showMemoryStats: root.showMemoryStats, - child: app, + return AccountScope( + key: ValueKey(scope), + onActive: _onSessionActive, + child: _buildApp(root), ); }, - home: EmergencyNoticeGate( - child: LoaderOverlay( - child: Breaker( - breaker: BreakerArea.global, - child: BlocConsumer( - listenWhen: (previous, current) => - previous.status != current.status, - listener: (context, accountState) { - final wasLoggedIn = _lastStatus == AccountStatus.loggedIn; - _lastStatus = accountState.status; - // Fresh login (loggedOut -> loggedIn): pull capability flags - // for the newly authenticated user, then register push right - // away instead of deferring it to the next app start. - if (accountState.status == AccountStatus.loggedIn) { - final settingsCubit = context.read(); - final capabilitiesCubit = context - .read(); - unawaited( - capabilitiesCubit.load().then((_) { - if (!mounted) return; - _syncPush(settingsCubit, capabilitiesCubit); - _applyRoleDefaults(settingsCubit, capabilitiesCubit); - }), - ); - unawaited( - context.read().load(), - ); - _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 to the next account. A cold-start share that - // is merely waiting for the stored session to resolve as - // signed out stays, to be sent after login. - if (wasLoggedIn) SessionWipe.clearImmediate(); - // 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 timetableBloc = context.read(); - final chatListBloc = context.read(); - final chatBloc = context.read(); - final breakerBloc = context.read(); - final capabilitiesCubit = 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( - SessionWipe.run( - timetableBloc: timetableBloc, - chatListBloc: chatListBloc, - 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, - ), - ), - ], - ); - case AccountStatus.loggedOut: - return const Login(); - case AccountStatus.undefined: - return const AccountLoadingScreen(); - } - }, - ), - ), - ), - ), ); }, ), ); + + Widget _buildApp(_RootSettings root) => MaterialApp( + showPerformanceOverlay: root.showPerformanceOverlay, + showSemanticsDebugger: root.showSemanticsDebugger, + 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: _navigatorObservers, + localizationsDelegates: const [ + ...GlobalMaterialLocalizations.delegates, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: const [Locale('de'), Locale('en')], + locale: const Locale('de'), + title: 'Marianum Fulda', + themeMode: root.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) { + Widget app = 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()), + ); + final scale = root.textScaleOverride; + if (scale > 0) { + app = MediaQuery.withClampedTextScaling( + minScaleFactor: scale, + maxScaleFactor: scale, + child: app, + ); + } + return DebugStatsOverlay( + showFrameStats: root.showFrameStats, + showMemoryStats: root.showMemoryStats, + child: app, + ); + }, + home: EmergencyNoticeGate( + child: LoaderOverlay( + child: Breaker( + breaker: BreakerArea.global, + child: BlocBuilder( + buildWhen: (previous, current) => previous.status != current.status, + 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), + ), + ], + ); + case AccountStatus.loggedOut: + return const Login(); + case AccountStatus.addingAccount: + return const Login(addingAccount: true); + case AccountStatus.undefined: + return const AccountLoadingScreen(); + } + }, + ), + ), + ), + ), + ); } typedef _RootSettings = ({ ThemeMode appTheme, - String mcBaseUrl, bool notificationsEnabled, bool showPerformanceOverlay, bool showSemanticsDebugger, diff --git a/lib/model/session_wipe.dart b/lib/model/session_wipe.dart index 5efa03e..3bcbefc 100644 --- a/lib/model/session_wipe.dart +++ b/lib/model/session_wipe.dart @@ -4,11 +4,9 @@ import 'dart:io'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter_app_badge/flutter_app_badge.dart'; -import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import '../api/cache_store.dart'; import '../api/marianumcloud/talk/share_files_to_chat.dart'; import '../background/widget_background_task.dart'; import '../notification/notification_service.dart'; @@ -18,20 +16,14 @@ import '../push/push_keypair.dart'; import '../push/push_tap_router.dart'; import '../routing/app_routes.dart'; import '../share_intent/share_intent_listener.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/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart'; -import '../state/app/modules/timetable/bloc/timetable_bloc.dart'; import '../utils/app_paths.dart'; import '../utils/downloads/download_manager.dart'; import '../utils/file_clipboard.dart'; import '../widget_data/widget_sync.dart'; -/// Removes everything the signed-out account left on the device. Every step -/// is isolated: one failing step used to skip all later ones and leave the -/// previous account's data behind. +/// Removes what an account leaves on the device outside its own storage (see +/// `AccountStorage` for that). Every step is isolated: one failing step used +/// to skip all later ones and leave the previous account's data behind. abstract final class SessionWipe { static Future? _running; @@ -39,8 +31,9 @@ abstract final class SessionWipe { /// wipe could clear what the next account just stored (widget job, caches). static Future get done => _running ?? Future.value(); - /// State that must be gone before the next frame (a login screen can be - /// reached right away): pending navigation and in-memory singletons. + /// State that must be gone before the next frame (a login screen or another + /// account can be reached right away): pending navigation and in-memory + /// singletons. static void clearImmediate() { _step('share intents', ShareIntentListener.instance.clearAll); _step('share folder cache', resetTalkShareFolderCache); @@ -52,66 +45,17 @@ abstract final class SessionWipe { _step('downloads', DownloadManager.instance.clearAll); } - static Future run({ - required TimetableBloc timetableBloc, - required ChatListBloc chatListBloc, - required ChatBloc chatBloc, - required BreakerBloc breakerBloc, - required CapabilitiesCubit capabilitiesCubit, - required NextcloudCapabilitiesCubit nextcloudCapabilitiesCubit, - }) { - final wipe = _run( - timetableBloc: timetableBloc, - chatListBloc: chatListBloc, - chatBloc: chatBloc, - breakerBloc: breakerBloc, - capabilitiesCubit: capabilitiesCubit, - nextcloudCapabilitiesCubit: nextcloudCapabilitiesCubit, - ); - _running = wipe; - return wipe.whenComplete(() { - if (identical(_running, wipe)) _running = null; - }); - } - - static Future _run({ - required TimetableBloc timetableBloc, - required ChatListBloc chatListBloc, - required ChatBloc chatBloc, - required BreakerBloc breakerBloc, - required CapabilitiesCubit capabilitiesCubit, - required NextcloudCapabilitiesCubit nextcloudCapabilitiesCubit, - }) async { - // SettingsCubit is deliberately left alone: its BlocBuilder wraps - // MaterialApp, and emitting a fresh state here tore down the freshly - // mounted Login tree (blank screen until the next interaction). - await Future.wait([ - _stepAsync('timetable', timetableBloc.reset), - _stepAsync('chat list', chatListBloc.reset), - _stepAsync('chat', chatBloc.reset), - _stepAsync('breakers', breakerBloc.reset), - _stepAsync('capabilities', capabilitiesCubit.reset), - _stepAsync('nc capabilities', nextcloudCapabilitiesCubit.reset), - ]); - await _stepAsync('shared preferences', () async { - await (await SharedPreferences.getInstance()).clear(); - }); - await _stepAsync('hydrated storage', HydratedBloc.storage.clear); - await _stepAsync('request cache', CacheStore.instance.clear); - await _stepAsync('chat background', () async { - final image = File(AppPaths.chatBackgroundImage); - if (image.existsSync()) await image.delete(); - }); + /// Runs whenever the active account stops being active (sign-out or + /// switch). The tray and its bookkeeping belong to that account — a tap or + /// inline reply there would otherwise act as the next one. A new keypair + /// and FCM token make pushes still addressed to the previous registration + /// undecryptable and undeliverable (an offline sign-out or switch could not + /// unregister it). + static Future accountLeft() async { await _stepAsync('download files', () async { final dir = Directory('${(await getTemporaryDirectory()).path}/downloads'); if (dir.existsSync()) await dir.delete(recursive: true); }); - - // Push: the tray and its bookkeeping belong to the previous account — a - // tap or inline reply there would otherwise act as the next one. A new - // keypair and FCM token make pushes still addressed to the previous - // registration undecryptable and undeliverable (an offline sign-out - // could not unregister it). await _stepAsync( 'notification tray', NotificationService().flutterLocalNotificationsPlugin.cancelAll, @@ -120,8 +64,35 @@ abstract final class SessionWipe { await _stepAsync('push nid store', NidStore().clear); await _stepAsync('push thread store', ChatThreadStore().clearAll); await _stepAsync('push keypair', const PushKeypair().clear); - await _stepAsync('fcm token', FirebaseMessaging.instance.deleteToken); + // Bounded: the next account waits for this, also without a network. + await _stepAsync( + 'fcm token', + () => FirebaseMessaging.instance.deleteToken().timeout( + const Duration(seconds: 5), + ), + ); + } + /// Wipes what outlives accounts, once the last one signed out. + static Future deviceLeft() { + final wipe = _deviceLeft(); + _running = wipe; + return wipe.whenComplete(() { + if (identical(_running, wipe)) _running = null; + }); + } + + static Future _deviceLeft() async { + // SettingsCubit is deliberately left alone: its selector wraps + // MaterialApp, and emitting a fresh state here tore down the freshly + // mounted Login tree (blank screen until the next interaction). + await _stepAsync('shared preferences', () async { + await (await SharedPreferences.getInstance()).clear(); + }); + await _stepAsync('chat background', () async { + final image = File(AppPaths.chatBackgroundImage); + if (image.existsSync()) await image.delete(); + }); // 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. diff --git a/lib/push/push_registration.dart b/lib/push/push_registration.dart index eaabb46..f8a75d4 100644 --- a/lib/push/push_registration.dart +++ b/lib/push/push_registration.dart @@ -427,6 +427,13 @@ class PushRegistration { /// first, then the proxy) with the new token. Future onTokenRefresh() => register(); + /// Stops pushes for the active account before another one takes over. The + /// app passwords stay valid so switching back can re-register silently. + Future deactivate() async { + if (DemoMode.active || _nextcloudOrNull == null) return; + await unregister(); + } + /// Full teardown for logout: unregister push, revoke BOTH app passwords /// (each authenticated with itself — the endpoint revokes the credential it /// is called with), then clear them locally. Ordered so the proxy stops diff --git a/lib/routing/app_routes.dart b/lib/routing/app_routes.dart index bfb8298..428eb55 100644 --- a/lib/routing/app_routes.dart +++ b/lib/routing/app_routes.dart @@ -64,7 +64,9 @@ class AppRoutes { /// Root navigator key, set on [MaterialApp]. Lets globally-mounted UI (e.g. /// the downloads tray, which lives above the navigator in `MaterialApp.builder` /// and is therefore never covered by a pushed route) open full-page routes. - static final GlobalKey rootNavigatorKey = + /// Replaced per account (see `Main`), so a switch starts on a fresh + /// navigator. + static GlobalKey rootNavigatorKey = GlobalKey(); /// A context that is a descendant of the root navigator (its overlay), safe to diff --git a/lib/session/account_codec.dart b/lib/session/account_codec.dart new file mode 100644 index 0000000..7270af3 --- /dev/null +++ b/lib/session/account_codec.dart @@ -0,0 +1,218 @@ +import 'dart:convert'; + +import 'session.dart'; +import 'session_codec.dart'; + +/// One signed-in account on this device. The session itself lives in the +/// keychain (active slots or the account's vault); this is the index entry. +class AccountEntry { + final String id; + final String kind; + + /// Login name — the identity of the account. + final String label; + + /// Real name as known to the server; null until it was loaded once. + final String? displayName; + final bool isDemo; + + /// Storage namespace of the account's local data. Empty for the account that + /// existed before multi-account support, so its data stays where it was. + final String namespace; + final int lastUsed; + + const AccountEntry({ + required this.id, + required this.kind, + required this.label, + required this.namespace, + this.displayName, + this.isDemo = false, + this.lastUsed = 0, + }); + + AccountEntry copyWith({int? lastUsed, String? displayName}) => AccountEntry( + id: id, + kind: kind, + label: label, + namespace: namespace, + displayName: displayName ?? this.displayName, + isDemo: isDemo, + lastUsed: lastUsed ?? this.lastUsed, + ); + + Map toJson() => { + 'id': id, + 'kind': kind, + 'label': label, + 'namespace': namespace, + 'displayName': displayName, + 'isDemo': isDemo, + 'lastUsed': lastUsed, + }; + + static AccountEntry? fromJson(Object? json) { + if (json is! Map) return null; + final id = json['id']; + final kind = json['kind']; + final label = json['label']; + if (id is! String || kind is! String || label is! String) return null; + return AccountEntry( + id: id, + kind: kind, + label: label, + namespace: json['namespace'] is String ? json['namespace'] as String : id, + displayName: json['displayName'] is String + ? json['displayName'] as String + : null, + isDemo: json['isDemo'] == true, + lastUsed: json['lastUsed'] is int ? json['lastUsed'] as int : 0, + ); + } +} + +class AccountIndex { + final List accounts; + final String? activeId; + + const AccountIndex({this.accounts = const [], this.activeId}); + + static const empty = AccountIndex(); + + AccountEntry? get active => byId(activeId); + + AccountEntry? byId(String? id) { + if (id == null) return null; + for (final entry in accounts) { + if (entry.id == id) return entry; + } + return null; + } + + /// The entry [session] belongs to — same kind, identity and demo flag. + AccountEntry? matching(Session session) { + final (kind, label) = identityOf(session); + for (final entry in accounts) { + if (entry.kind == kind && + entry.label == label && + entry.isDemo == session.isDemo) { + return entry; + } + } + return null; + } + + /// Makes [session] the active account, reusing its entry when it is already + /// known and otherwise adding one with [newId] (also its namespace). + AccountIndex activate( + Session session, { + required String newId, + String? namespace, + int now = 0, + }) { + final existing = matching(session); + if (existing != null) return select(existing.id, now: now); + final (kind, label) = identityOf(session); + return AccountIndex( + accounts: [ + ...accounts, + AccountEntry( + id: newId, + kind: kind, + label: label, + namespace: namespace ?? newId, + isDemo: session.isDemo, + lastUsed: now, + ), + ], + activeId: newId, + ); + } + + AccountIndex select(String id, {int now = 0}) => AccountIndex( + accounts: accounts + .map((e) => e.id == id ? e.copyWith(lastUsed: now) : e) + .toList(), + activeId: id, + ); + + AccountIndex rename(String id, String displayName) => AccountIndex( + accounts: accounts + .map((e) => e.id == id ? e.copyWith(displayName: displayName) : e) + .toList(), + activeId: activeId, + ); + + /// Drops [id]; the active account is left unset when it was the one removed. + AccountIndex remove(String id) => AccountIndex( + accounts: [ + for (final e in accounts) + if (e.id != id) e, + ], + activeId: activeId == id ? null : activeId, + ); + + /// Most recently used account other than [excludedId], if any. + AccountEntry? mostRecentExcept(String? excludedId) { + AccountEntry? best; + for (final entry in accounts) { + if (entry.id == excludedId) continue; + if (best == null || entry.lastUsed > best.lastUsed) best = entry; + } + return best; + } + + String encode() => jsonEncode({ + 'activeId': activeId, + 'accounts': [for (final e in accounts) e.toJson()], + }); + + static AccountIndex decode(String? raw) { + if (raw == null || raw.isEmpty) return empty; + try { + final json = jsonDecode(raw); + if (json is! Map) return empty; + final list = json['accounts']; + return AccountIndex( + accounts: [ + if (list is List) + for (final item in list) ?AccountEntry.fromJson(item), + ], + activeId: json['activeId'] is String + ? json['activeId'] as String + : null, + ); + } on FormatException { + return empty; + } + } +} + +(String kind, String label) identityOf(Session session) => switch (session) { + CredentialSession(:final username) => (SessionKeys.kindCredential, username), +}; + +/// Keychain fields of [session] including the app passwords, as stored in an +/// inactive account's vault. Unset fields are omitted. +Map sessionVaultFields(Session session) => { + for (final MapEntry(:key, :value) in encodeSessionFields(session).entries) + key: ?value, + SessionKeys.appPassword: ?session.nextcloud?.appPassword, + SessionKeys.appPasswordTalk: ?session.nextcloud?.appPasswordTalk, +}; + +String encodeVault(Map fields) => jsonEncode(fields); + +Map decodeVault(String? raw) { + if (raw == null || raw.isEmpty) return const {}; + try { + final json = jsonDecode(raw); + if (json is! Map) return const {}; + return { + for (final MapEntry(:key, :value) in json.entries) + if (key is String && value is String) key: value, + }; + } on FormatException { + return const {}; + } +} diff --git a/lib/session/session_lifecycle.dart b/lib/session/session_lifecycle.dart index 0526f0d..2392692 100644 --- a/lib/session/session_lifecycle.dart +++ b/lib/session/session_lifecycle.dart @@ -1,22 +1,36 @@ +import 'dart:async'; import 'dart:developer'; import 'package:flutter/foundation.dart'; +import '../api/marianumcloud/app_password/delete_app_password.dart'; +import '../api/marianumconnect/auth/auth_interceptor.dart'; +import '../api/marianumconnect/auth/token_storage.dart'; import '../api/marianumconnect/queries/auth_logout/auth_logout.dart'; +import '../api/marianumconnect/queries/auth_me/auth_me.dart'; +import '../background/widget_background_task.dart'; +import '../model/session_wipe.dart'; import '../push/push_registration.dart'; +import '../storage/account_storage.dart'; +import '../widget_data/widget_sync.dart'; +import 'session.dart'; import 'session_manager.dart'; abstract final class SessionLifecycle { /// Why the last sign-out happened, when the user did not ask for it. The - /// login screen shows it once and clears it — without it an expired session - /// just drops the user on the login screen with no explanation. + /// login screen (or, when another account takes over, the app) shows it + /// once and clears it. static final ValueNotifier signOutNotice = ValueNotifier(null); + /// Account to return to while "add account" runs; null otherwise. + static String? _addReturnId; + /// Ordered teardown: unregister push and revoke the Nextcloud app passwords /// (while those credentials still exist), then revoke the MC bearer token, - /// finally wipe the local session. Each step is best-effort so an offline - /// sign-out still reaches a clean local state. - static Future signOut({String? notice}) async { + /// finally wipe the local session and its data. Each step is best-effort so + /// an offline sign-out still reaches a clean local state. Another signed-in + /// account takes over when there is one; returns its id. + static Future signOut({String? notice}) async { signOutNotice.value = notice; try { await PushRegistration().logoutCleanup(); @@ -24,6 +38,166 @@ abstract final class SessionLifecycle { log('Sign-out: push cleanup failed: $e'); } await AuthLogout().run(); - await SessionManager().signOut(); + final removed = await SessionManager().signOut(); + SessionWipe.clearImmediate(); + await SessionWipe.accountLeft(); + if (removed != null) await AccountStorage.delete(removed.namespace); + + for ( + var next = SessionManager().accounts.value.mostRecentExcept(null); + next != null; + next = SessionManager().accounts.value.mostRecentExcept(null) + ) { + try { + await SessionManager().activate(next.id); + break; + } on Object catch (e) { + log('Sign-out: taking over ${next.id} failed: $e'); + await SessionManager().forget(next.id); + } + } + await AccountStorage.activate(SessionManager().activeAccount); + if (SessionManager().isSignedIn) await _resetWidget(); + return SessionManager().activeAccount?.id; + } + + /// Makes the stored account [id] the active one. Push moves along: the + /// previous account is unregistered here, the new one registers once the + /// app has remounted for it. + static Future switchTo(String id) async { + final previous = SessionManager().activeAccount; + if (previous?.id == id) return; + await MarianumConnectAuthInterceptor.idle(); + try { + await PushRegistration().deactivate(); + } on Object catch (e) { + log('Switch: push deactivation failed: $e'); + } + await SessionManager().stashActive(); + SessionWipe.clearImmediate(); + var restored = false; + try { + await SessionManager().activate(id); + } on Object { + if (previous != null) { + await SessionManager().activate(previous.id); + restored = true; + } + rethrow; + } finally { + await SessionWipe.accountLeft(); + await AccountStorage.activate(SessionManager().activeAccount); + // Nothing remounts for the account that stays, so its push would + // remain unregistered until the next start. + if (restored) unawaited(PushRegistration().register()); + } + await _resetWidget(); + } + + /// Parks the active account before the login screen signs in another one. + static Future beginAddAccount() async { + await SessionManager().stashActive(); + _addReturnId = SessionManager().activeAccount?.id; + SessionWipe.clearImmediate(); + // Whatever runs behind the login screen must not write into the parked + // account's data. + await AccountStorage.activate(null); + } + + static bool get isAddingAccount => _addReturnId != null; + + /// Leaves "add account" without a new account: a half-finished sign-in is + /// dropped and the previous account restored. + static Future cancelAddAccount() async { + final returnId = _addReturnId; + _addReturnId = null; + if (returnId == null) return; + if (SessionManager().activeAccount?.id != returnId && + SessionManager().isSignedIn) { + await SessionManager().signOut(); + } + await SessionManager().activate(returnId); + await AccountStorage.activate(SessionManager().activeAccount); + } + + /// Called once a login finished. After "add account" the previous account's + /// push is unregistered with its own credentials before the new one takes + /// over. Returns the id of the now active account. + static Future finishLogin() async { + final returnId = _addReturnId; + _addReturnId = null; + final added = SessionManager().activeAccount!; + if (returnId != null && returnId != added.id) { + await SessionManager().stashActive(); + await SessionManager().activate(returnId); + try { + await PushRegistration().deactivate(); + } on Object catch (e) { + log('Add account: push deactivation failed: $e'); + } + await SessionManager().activate(added.id); + await SessionWipe.accountLeft(); + } + await AccountStorage.activate(SessionManager().activeAccount); + return added.id; + } + + /// Refreshes the stored real name of the active account (best-effort). + static Future refreshDisplayName() async { + if (SessionManager().current case final session? when !session.isDemo) { + try { + final user = await AuthMe().user(); + final name = '${user.firstName} ${user.lastName}'.trim(); + if (name.isNotEmpty) await SessionManager().setDisplayName(name); + } on Object catch (e) { + log('Display name refresh failed: $e'); + } + } + } + + /// Signs out an account that is not active: revokes its tokens with the + /// stored credentials (best-effort) and deletes its local data. Its push + /// was already unregistered when it stopped being active. + static Future removeInactive(String id) async { + final entry = SessionManager().accounts.value.byId(id); + if (entry == null || entry.id == SessionManager().activeAccount?.id) return; + final (session, fields) = await SessionManager().readVault(id); + if (session != null && !session.isDemo) { + await _revoke(session, fields[MarianumConnectTokenStorage.bearerKey]); + } + await SessionManager().forget(id); + await AccountStorage.delete(entry.namespace); + } + + static Future _revoke(Session session, String? token) async { + if (token != null && token.isNotEmpty) await AuthLogout.revoke(token); + final nextcloud = session.nextcloud; + if (nextcloud == null) return; + try { + if (nextcloud.hasAppPassword) { + await DeleteAppPassword().run( + authorizationHeader: nextcloud.basicAuthHeader, + ); + } + if (nextcloud.hasAppPasswordTalk) { + await DeleteAppPassword().run( + authorizationHeader: nextcloud.talkBasicAuthHeader, + ); + } + } on Object catch (e) { + log('Remove account: app password revoke failed: $e'); + } + } + + /// The widget still shows the previous account's plan; the app republishes + /// once it mounted, the refresh covers a backgrounded app. + static Future _resetWidget() async { + try { + await WidgetSync.clear(); + await WidgetSync.triggerUpdate(); + unawaited(WidgetBackgroundTask.requestImmediateRefresh()); + } on Object catch (e) { + log('Widget reset failed: $e'); + } } } diff --git a/lib/session/session_manager.dart b/lib/session/session_manager.dart index 44c17e8..58b25af 100644 --- a/lib/session/session_manager.dart +++ b/lib/session/session_manager.dart @@ -2,17 +2,26 @@ import 'dart:async'; import 'dart:developer'; import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../api/marianumconnect/auth/token_storage.dart'; import '../push/push_secure_storage.dart'; import '../utils/exponential_backoff.dart'; +import '../utils/random_id.dart'; +import 'account_codec.dart'; import 'nextcloud_credentials.dart'; import 'session.dart'; import 'session_codec.dart'; /// Owns the active [Session] and its persistence. One instance per isolate; /// the widget background isolate reads the same keychain. +/// +/// Several accounts can be signed in; the frozen [SessionKeys] slots, the MC +/// token and the group-keychain app passwords always hold the *active* one +/// (native code and the background isolate read only those). Inactive +/// accounts are parked in a per-account vault entry. class SessionManager { // `first_unlock` so a background launch on a locked device (silent push, // BGAppRefresh) can still read the session. Items written by older versions @@ -32,6 +41,10 @@ class SessionManager { SessionKeys.loginFlow, ]; + static const _indexKey = 'accounts_index'; + static String _vaultKey(String id) => 'account_vault_$id'; + static const _tokenStorage = MarianumConnectTokenStorage(); + static final SessionManager _instance = SessionManager._(); factory SessionManager() => _instance; @@ -46,7 +59,8 @@ class SessionManager { int _sessionEpoch = 0; - /// Bumped whenever the signed-in account changes. Async work captures it when it starts and drops its + /// Bumped whenever the active account changes (sign-out, switch, another + /// account signing in). Async work captures it when it starts and drops its /// result when it changed meanwhile, so a request of the previous account /// cannot land in the next account's state or cache. int get sessionEpoch => _sessionEpoch; @@ -58,6 +72,13 @@ class SessionManager { /// Called from `main()`; background entry points never run it. void markUiEngine() => _isUiEngine = true; + /// Every signed-in account and which one is active. + final ValueNotifier accounts = ValueNotifier( + AccountIndex.empty, + ); + + AccountEntry? get activeAccount => accounts.value.active; + bool get isSignedIn => _current != null; bool get isDemo => _current?.isDemo ?? false; @@ -85,8 +106,22 @@ class SessionManager { NextcloudCredentials requireNextcloud() => _current?.nextcloud ?? (throw const NextcloudUnavailableException()); - /// Replaces any stored session completely; no prior [signOut] needed. + /// Makes [session] the active account, replacing the active slots + /// completely. An account signed in before keeps its entry; call + /// [stashActive] first so the previously active one stays switchable. Future signIn(Session session) async { + await _writeActive(session); + await _saveIndex( + accounts.value.activate( + session, + newId: randomHexId(bytes: 8), + now: _now(), + ), + ); + if (!_loaded.isCompleted) _loaded.complete(); + } + + Future _writeActive(Session session) async { if (!_isSameAccount(_current, session)) _sessionEpoch++; await Future.wait([ for (final MapEntry(:key, :value) in encodeSessionFields(session).entries) @@ -101,10 +136,12 @@ class SessionManager { ), ]); _current = session; - if (!_loaded.isCompleted) _loaded.complete(); } - Future signOut() async { + /// Wipes the active slots and forgets the active account. Other accounts + /// stay in their vaults; see [activate]. + Future signOut() async { + final removed = activeAccount; _sessionEpoch++; _loaded = Completer(); _current = null; @@ -112,14 +149,70 @@ class SessionManager { for (final field in _sessionFields) _secureStorage.delete(key: field), _writeGroupSecret(SessionKeys.appPassword, null), _writeGroupSecret(SessionKeys.appPasswordTalk, null), + if (removed != null) _secureStorage.delete(key: _vaultKey(removed.id)), ]); + if (removed != null) await _saveIndex(accounts.value.remove(removed.id)); + return removed; } - static bool _isSameAccount(Session? a, Session? b) => switch ((a, b)) { - (final CredentialSession a, final CredentialSession b) => - a.username == b.username && a.isDemo == b.isDemo, - _ => a == b, - }; + /// Parks the active account (session, app passwords, MC token) in its vault + /// so the slots can be taken over by another account. + Future stashActive() async { + final session = _current; + final entry = activeAccount; + if (session == null || entry == null) return; + final fields = { + ...sessionVaultFields(session), + ...await _tokenStorage.readAll(), + }; + await _secureStorage.write( + key: _vaultKey(entry.id), + value: encodeVault(fields), + ); + } + + /// Loads the vault of [id] into the active slots. The active account must + /// have been stashed before, or its session is lost. + Future activate(String id) async { + final fields = decodeVault(await _secureStorage.read(key: _vaultKey(id))); + final session = decodeSession(fields); + if (session == null) throw StateError('No stored session for account $id'); + await _writeActive(session); + await _tokenStorage.writeAll(fields); + await _saveIndex(accounts.value.select(id, now: _now())); + if (!_loaded.isCompleted) _loaded.complete(); + } + + /// Remembers the real name of the active account for the account list. + Future setDisplayName(String displayName) async { + final entry = activeAccount; + if (entry == null || entry.displayName == displayName) return; + await _saveIndex(accounts.value.rename(entry.id, displayName)); + } + + /// Session and MC token of an inactive account, e.g. to revoke them. + Future<(Session?, Map)> readVault(String id) async { + final fields = decodeVault(await _secureStorage.read(key: _vaultKey(id))); + return (decodeSession(fields), fields); + } + + /// Drops an inactive account without touching the active slots. + Future forget(String id) async { + await _secureStorage.delete(key: _vaultKey(id)); + await _saveIndex(accounts.value.remove(id)); + } + + static bool _isSameAccount(Session? a, Session? b) { + if (a == null || b == null) return a == b; + return identityOf(a) == identityOf(b) && a.isDemo == b.isDemo; + } + + Future _saveIndex(AccountIndex index) async { + accounts.value = index; + await _secureStorage.write(key: _indexKey, value: index.encode()); + } + + static int _now() => DateTime.now().millisecondsSinceEpoch; /// Persists a freshly minted Nextcloud app password; from then on every /// Nextcloud call authenticates with it instead of the real password. @@ -202,6 +295,7 @@ class SessionManager { await _migrateFromLegacyStorage(); await _migrateKeychainAccessibility(); _current = await _readActive(); + await _loadIndex(); if (!_loaded.isCompleted) _loaded.complete(); } @@ -226,28 +320,60 @@ class SessionManager { return decodeSession(raw); } - /// Username currently in the keystore. Other engines (widget task, push - /// isolates) sign out or in without this instance noticing. + /// Username currently in the active slots. Other engines (widget task, push + /// isolates) sign out, in or switch without this instance noticing. Future readStoredUsername() => _secureStorage.read(key: SessionKeys.username); /// Re-reads the session for long-lived background engines: they load once - /// and would otherwise keep acting with an account that signed out in the - /// app meanwhile. Keeps the known state when the keystore is unreadable. + /// and would otherwise keep acting with an account that signed out or was + /// switched away from in the app meanwhile. Keeps the known state when the + /// keystore is unreadable. Future reloadFromStorage() async { - // The UI engine performs sign-in and sign-out itself, so its state is - // current; re-reading mid sign-out could resurrect the removed account. + // The UI engine performs sign-in, sign-out and switches itself, so its + // state is current; re-reading in between could resurrect the removed + // account. if (_isUiEngine) return; try { final session = await _readActive(); + final index = AccountIndex.decode( + await _secureStorage.read(key: _indexKey), + ); if (!_isSameAccount(_current, session)) _sessionEpoch++; _current = session; + accounts.value = index; if (!_loaded.isCompleted) _loaded.complete(); } on Object catch (e) { log('Session reload failed, keeping loaded state: $e'); } } + Future _loadIndex() async { + var index = AccountIndex.decode(await _secureStorage.read(key: _indexKey)); + final session = _current; + if (session != null) { + if (index.active == null || + index.matching(session)?.id != index.activeId) { + // First start after the update: the existing account keeps its data + // in the un-namespaced storage. + index = index.activate( + session, + newId: randomHexId(bytes: 8), + namespace: index.accounts.isEmpty ? '' : null, + now: _now(), + ); + await _secureStorage.write(key: _indexKey, value: index.encode()); + } + accounts.value = index; + return; + } + accounts.value = index.remove(index.activeId ?? ''); + // Interrupted switch or sign-out: fall back to another stored account + // instead of showing the login screen. + final fallback = accounts.value.mostRecentExcept(null); + if (fallback != null) await activate(fallback.id); + } + // Move credentials from the old SharedPreferences plain-text storage into the // platform's secure keystore. Run once per install and clear the legacy keys. Future _migrateFromLegacyStorage() async { diff --git a/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart index c09ea2a..71aaec0 100644 --- a/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart +++ b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart @@ -126,6 +126,14 @@ abstract class LoadableHydratedBloc< add(Reset()); } + // Blocs are rebuilt per account (see AccountScope). One that briefly + // outlives its account would otherwise load with the next account's + // credentials and persist the result into its own storage. + final int _ownSession = SessionManager().sessionEpoch; + + /// Whether the account this bloc was created for is still the active one. + bool get ownsSession => SessionManager().isCurrentSession(_ownSession); + static const _sessionKey = #loadableSessionEpoch; /// Runs [body] tagged with the current session: events it adds, also from @@ -136,6 +144,7 @@ abstract class LoadableHydratedBloc< @override void add(LoadableHydratedBlocEvent event) { + if (!ownsSession) return; final epoch = Zone.current[_sessionKey]; if (epoch is int && !SessionManager().isCurrentSession(epoch)) return; super.add(event); @@ -173,6 +182,7 @@ abstract class LoadableHydratedBloc< final SessionSingleFlight _fetchFlight = SessionSingleFlight(); void fetch() { + if (!ownsSession) return; unawaited( _fetchFlight.run(() { log('Fetching data for ${TState.toString()}'); diff --git a/lib/state/app/modules/account/account_scope.dart b/lib/state/app/modules/account/account_scope.dart new file mode 100644 index 0000000..e0d5136 --- /dev/null +++ b/lib/state/app/modules/account/account_scope.dart @@ -0,0 +1,66 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../breaker/bloc/breaker_bloc.dart'; +import '../capabilities/bloc/capabilities_cubit.dart'; +import '../chat/bloc/chat_bloc.dart'; +import '../chat_list/bloc/chat_list_bloc.dart'; +import '../nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart'; +import '../timetable/bloc/timetable_bloc.dart'; +import 'bloc/account_bloc.dart'; +import 'bloc/account_state.dart'; + +/// The blocs of one account. Keyed by the account id above, so a switch +/// recreates all of them from the new account's storage instead of resetting +/// them one by one. +class AccountScope extends StatelessWidget { + /// Called with a context below the account's blocs once the scope mounted + /// for a signed-in account. + final void Function(BuildContext scopeContext) onActive; + final Widget child; + + const AccountScope({super.key, required this.onActive, required this.child}); + + @override + Widget build(BuildContext context) => MultiBlocProvider( + providers: [ + BlocProvider(create: (_) => BreakerBloc()), + BlocProvider(create: (_) => CapabilitiesCubit()), + BlocProvider( + create: (_) => NextcloudCapabilitiesCubit(), + ), + BlocProvider(create: (_) => ChatListBloc()), + BlocProvider( + create: (ctx) => ChatBloc(chatListBloc: ctx.read()), + ), + BlocProvider(create: (_) => TimetableBloc()), + ], + child: _Activation(onActive: onActive, child: child), + ); +} + +class _Activation extends StatefulWidget { + final void Function(BuildContext scopeContext) onActive; + final Widget child; + + const _Activation({required this.onActive, required this.child}); + + @override + State<_Activation> createState() => _ActivationState(); +} + +class _ActivationState extends State<_Activation> { + @override + void initState() { + super.initState(); + if (context.read().state.status != AccountStatus.loggedIn) { + return; + } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) widget.onActive(context); + }); + } + + @override + Widget build(BuildContext context) => widget.child; +} diff --git a/lib/state/app/modules/account/bloc/account_bloc.dart b/lib/state/app/modules/account/bloc/account_bloc.dart index f1d2b6a..c02a24a 100644 --- a/lib/state/app/modules/account/bloc/account_bloc.dart +++ b/lib/state/app/modules/account/bloc/account_bloc.dart @@ -4,12 +4,38 @@ import 'account_event.dart'; import 'account_state.dart'; class AccountBloc extends Bloc { - AccountBloc({AccountStatus initialStatus = AccountStatus.undefined}) - : super(AccountState(status: initialStatus)) { + AccountBloc({ + AccountStatus initialStatus = AccountStatus.undefined, + String? accountId, + }) : super(AccountState(status: initialStatus, accountId: accountId)) { on( - (event, emit) => emit(state.copyWith(status: event.status)), + (event, emit) => emit( + AccountState( + status: event.status, + accountId: event.status == AccountStatus.loggedOut + ? null + : state.accountId, + ), + ), + ); + on( + (event, emit) => emit( + AccountState( + status: AccountStatus.loggedIn, + accountId: event.accountId, + freshLogin: event.freshLogin, + ), + ), ); } void setStatus(AccountStatus status) => add(AccountStatusChanged(status)); + + /// [accountId] became the active account (login, switch or takeover after a + /// sign-out); null means no account is left. + void activated(String? accountId, {bool freshLogin = false}) => add( + accountId == null + ? const AccountStatusChanged(AccountStatus.loggedOut) + : AccountActivated(accountId, freshLogin: freshLogin), + ); } diff --git a/lib/state/app/modules/account/bloc/account_event.dart b/lib/state/app/modules/account/bloc/account_event.dart index a3a0f8a..b740e8a 100644 --- a/lib/state/app/modules/account/bloc/account_event.dart +++ b/lib/state/app/modules/account/bloc/account_event.dart @@ -8,3 +8,9 @@ class AccountStatusChanged extends AccountEvent { final AccountStatus status; const AccountStatusChanged(this.status); } + +class AccountActivated extends AccountEvent { + final String accountId; + final bool freshLogin; + const AccountActivated(this.accountId, {required this.freshLogin}); +} diff --git a/lib/state/app/modules/account/bloc/account_state.dart b/lib/state/app/modules/account/bloc/account_state.dart index 878407d..aa4f2c2 100644 --- a/lib/state/app/modules/account/bloc/account_state.dart +++ b/lib/state/app/modules/account/bloc/account_state.dart @@ -1,9 +1,18 @@ -enum AccountStatus { undefined, loggedIn, loggedOut } +enum AccountStatus { undefined, loggedIn, loggedOut, addingAccount } class AccountState { final AccountStatus status; - const AccountState({this.status = AccountStatus.undefined}); - AccountState copyWith({AccountStatus? status}) => - AccountState(status: status ?? this.status); + /// Active account; the account-scoped part of the app is keyed by it. + final String? accountId; + + /// Set when [accountId] came from a login (not a switch), to show the + /// post-login splash. + final bool freshLogin; + + const AccountState({ + this.status = AccountStatus.undefined, + this.accountId, + this.freshLogin = false, + }); } diff --git a/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart b/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart index 24128f8..54681e2 100644 --- a/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart +++ b/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart @@ -87,8 +87,10 @@ class ChatListBloc /// Concurrent callers (tab switch, poll, push, resume) join the running /// refresh instead of each fetching and re-emitting the full room list. - Future refresh({bool renew = true, bool silent = false}) => - _refreshFlight.run(() => _refresh(renew: renew, silent: silent)); + Future refresh({bool renew = true, bool silent = false}) async { + if (!ownsSession) return; + await _refreshFlight.run(() => _refresh(renew: renew, silent: silent)); + } /// Skips the refresh when the list was fetched within [maxAge]. Future refreshIfOlderThan(Duration maxAge) { diff --git a/lib/state/app/modules/settings/bloc/settings_cubit.dart b/lib/state/app/modules/settings/bloc/settings_cubit.dart index 4b420a4..8c2aa4a 100644 --- a/lib/state/app/modules/settings/bloc/settings_cubit.dart +++ b/lib/state/app/modules/settings/bloc/settings_cubit.dart @@ -14,7 +14,12 @@ class SettingsCubit extends HydratedCubit { Map? _lastEmittedJson; Timer? _silentSave; - SettingsCubit() : super(DefaultSettings.get()); + final Storage? _storage; + + // ignore: use_super_parameters + SettingsCubit({Storage? storage}) + : _storage = storage, + super(DefaultSettings.get(), storage: storage); Settings val({bool write = false}) { if (write) { @@ -50,7 +55,7 @@ class SettingsCubit extends HydratedCubit { if (_silentSave == null) return; _silentSave!.cancel(); _silentSave = null; - HydratedBloc.storage.write(storageToken, state.toJson()); + (_storage ?? HydratedBloc.storage).write(storageToken, state.toJson()); } void _emitFreshInstance() { diff --git a/lib/storage/account_storage.dart b/lib/storage/account_storage.dart new file mode 100644 index 0000000..8726c9e --- /dev/null +++ b/lib/storage/account_storage.dart @@ -0,0 +1,78 @@ +import 'dart:developer'; +import 'dart:io'; + +import 'package:hydrated_bloc/hydrated_bloc.dart'; + +import '../api/cache_store.dart'; +import '../session/account_codec.dart'; +import 'hydrated_storage_bootstrap.dart'; + +/// Per-account local data: every account gets its own HydratedBloc box and +/// request cache directory (see [CacheStore]), so switching back is instant +/// and offline. Only the app settings live in the shared [global] box. +abstract final class AccountStorage { + static const _settingsKey = 'SettingsCubit'; + + static late String _baseDir; + static final Map _open = {}; + static Storage _global = InMemoryStorage(); + + static Storage get global => _global; + + /// Opens the global box. Settings of installs from before multi-account + /// support are copied over from the legacy box once. + static Future init(String baseDir) async { + _baseDir = baseDir; + _global = await buildHydratedStorageWithFallback( + _ensureDir('$baseDir/hydrated_global'), + ); + HydratedBloc.storage = InMemoryStorage(); + if (_global.read(_settingsKey) != null) return; + final legacySettings = (await _storageFor('')).read(_settingsKey); + if (legacySettings != null) { + await _global.write(_settingsKey, legacySettings); + } + } + + /// Points HydratedBloc at [account]'s data; the request cache follows the + /// active account on its own. Blocs keep the storage they were created + /// with, so the per-account blocs have to be recreated afterwards. + static Future activate(AccountEntry? account) async { + HydratedBloc.storage = account == null + ? InMemoryStorage() + : await _storageFor(account.namespace); + } + + /// Removes all local data of the account with [namespace]. + static Future delete(String namespace) async { + try { + final storage = await _storageFor(namespace); + await storage.clear(); + // Closed boxes ignore writes, so blocs of the account that are still + // mounted until the remount cannot leave data behind. + await storage.close(); + _open.remove(namespace); + if (namespace.isNotEmpty) { + final dir = Directory(_dirFor(namespace)); + if (dir.existsSync()) await dir.delete(recursive: true); + } + } on Object catch (e, s) { + log('Account storage delete failed: $e', stackTrace: s); + } + await CacheStore.instance.deleteNamespace(namespace); + } + + static Future _storageFor(String namespace) async => + _open[namespace] ??= await buildHydratedStorageWithFallback( + _ensureDir(_dirFor(namespace)), + ); + + // The legacy account keeps the box at the root of the base directory. + static String _dirFor(String namespace) => + namespace.isEmpty ? _baseDir : '$_baseDir/accounts/$namespace'; + + static String _ensureDir(String path) { + Directory(path).createSync(recursive: true); + return path; + } +} diff --git a/lib/storage/hydrated_storage_bootstrap.dart b/lib/storage/hydrated_storage_bootstrap.dart index 08369b3..20b9bfc 100644 --- a/lib/storage/hydrated_storage_bootstrap.dart +++ b/lib/storage/hydrated_storage_bootstrap.dart @@ -9,19 +9,43 @@ import 'package:hydrated_bloc/hydrated_bloc.dart'; Future buildHydratedStorageWithFallback(String directoryPath) async { final directory = HydratedStorageDirectory(directoryPath); try { - return await HydratedStorage.build(storageDirectory: directory); + return await _build(directory); } catch (e, s) { log('HydratedStorage open failed, rebuilding: $e', stackTrace: s); } try { await _deleteHydratedBox(directoryPath); - return await HydratedStorage.build(storageDirectory: directory); + return await _build(directory); } catch (e, s) { log('HydratedStorage rebuild failed, using memory: $e', stackTrace: s); return InMemoryStorage(); } } +final Expando Function()> _keyReaders = Expando(); + +Future _build(HydratedStorageDirectory directory) async { + final storage = await HydratedStorage.build(storageDirectory: directory); + // hydrated_bloc exposes no key listing; the Hive instance is the only way + // in. Every build replaces the static one, so it is captured per storage. + // ignore: invalid_use_of_visible_for_testing_member + final hive = HydratedStorage.hive; + _keyReaders[storage] = () => + hive.box('hydrated_box').keys.map((k) => '$k').toList(); + return storage; +} + +/// All keys persisted in [storage], sorted; `null` when they can't be +/// enumerated. +List? hydratedStorageKeys(Storage storage) { + if (storage is InMemoryStorage) return storage.keys.toList()..sort(); + try { + return _keyReaders[storage]?.call()?..sort(); + } on Object { + return null; + } +} + Future _deleteHydratedBox(String directoryPath) async { final directory = Directory(directoryPath); if (!directory.existsSync()) return; diff --git a/lib/view/login/login.dart b/lib/view/login/login.dart index f559768..ea804b7 100644 --- a/lib/view/login/login.dart +++ b/lib/view/login/login.dart @@ -1,12 +1,13 @@ import 'dart:async'; +import 'dart:developer'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../background/widget_background_task.dart'; import '../../session/session_lifecycle.dart'; +import '../../session/session_manager.dart'; import '../../state/app/modules/account/bloc/account_bloc.dart'; -import '../../state/app/modules/account/bloc/account_state.dart'; import '../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../storage/dev_tools_settings.dart'; import '../../storage/settings.dart' as model; @@ -20,7 +21,10 @@ import 'widgets/login_branding.dart'; import 'widgets/login_card.dart'; class Login extends StatefulWidget { - const Login({super.key}); + /// Signs in another account while one is active; offers to go back. + final bool addingAccount; + + const Login({super.key, this.addingAccount = false}); @override State createState() => _LoginState(); @@ -65,59 +69,100 @@ class _LoginState extends State with SingleTickerProviderStateMixin { super.dispose(); } - void _onLoginSuccess() { + Future _onLoginSuccess() async { Haptics.heavyAccent(); + final accountBloc = context.read(); + // Fade the login content out before handing over to the post-login splash. + // Both share the red backdrop, so this reads as one continuous transition + // instead of an abrupt swap. + final finished = SessionLifecycle.finishLogin(); + await _fade.reverse().orCancel.onError((_, _) {}); + String? accountId; + try { + accountId = await finished; + } on Object catch (e) { + log('Login: finishing failed: $e'); + accountId = SessionManager().activeAccount?.id; + } // Re-register the periodic refresh (cancelAll runs on logout) and kick // off an immediate one-off so the widget populates within seconds // instead of waiting up to 30 minutes for the next periodic slot. unawaited(WidgetBackgroundTask.initialize()); unawaited(WidgetBackgroundTask.requestImmediateRefresh()); - // Fade the login content out before handing over to the post-login splash. - // Both share the red backdrop, so this reads as one continuous transition - // instead of an abrupt swap. - _fade.reverse().whenComplete(() { - if (!mounted) return; - context.read().setStatus(AccountStatus.loggedIn); - }); + accountBloc.activated(accountId, freshLogin: true); + } + + Future _cancelAddAccount() async { + final accountBloc = context.read(); + await SessionLifecycle.cancelAddAccount(); + accountBloc.activated(SessionManager().activeAccount?.id); } @override - Widget build(BuildContext context) => Scaffold( - backgroundColor: _marianumRed, - body: FadeTransition( - opacity: _fade, - child: SafeArea( - child: LayoutBuilder( - builder: (context, constraints) => SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Center( - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: constraints.maxHeight, - maxWidth: 420, + Widget build(BuildContext context) => PopScope( + canPop: !widget.addingAccount, + onPopInvokedWithResult: (didPop, _) { + if (!didPop && !_busy) unawaited(_cancelAddAccount()); + }, + child: Scaffold( + backgroundColor: _marianumRed, + appBar: widget.addingAccount + ? AppBar( + backgroundColor: Colors.transparent, + foregroundColor: Colors.white, + elevation: 0, + leading: ListenableBuilder( + listenable: _controller, + builder: (context, _) => IconButton( + icon: const Icon(Icons.close), + tooltip: 'Abbrechen', + onPressed: _busy ? null : _cancelAddAccount, ), - // spaceBetween statt Spacer-in-IntrinsicHeight: Letzteres würde - // die Column bei Inhaltsänderungen im unteren Block auf die - // intrinsic-Höhe pinnen und ein paar Pixel Overflow erzeugen. - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - children: [ - const LoginHeader(), - const SizedBox(height: 28), - LoginCard( - controller: _controller, - onSuccess: _onLoginSuccess, - ), - const SizedBox(height: 12), - ], - ), - const Column( - mainAxisSize: MainAxisSize.min, - children: [_EndpointLink(), LoginFooter()], - ), - ], + ), + title: const Text('Konto hinzufügen'), + ) + : null, + body: FadeTransition( + opacity: _fade, + child: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Center( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + maxWidth: 420, + ), + // spaceBetween statt Spacer-in-IntrinsicHeight: Letzteres würde + // die Column bei Inhaltsänderungen im unteren Block auf die + // intrinsic-Höhe pinnen und ein paar Pixel Overflow erzeugen. + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + children: [ + LoginHeader(addingAccount: widget.addingAccount), + const SizedBox(height: 28), + LoginCard( + controller: _controller, + onSuccess: _onLoginSuccess, + addingAccount: widget.addingAccount, + ), + const SizedBox(height: 12), + ], + ), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + // The new account must live on the server the app + // already talks to. + if (!widget.addingAccount) const _EndpointLink(), + const LoginFooter(), + ], + ), + ], + ), ), ), ), @@ -126,6 +171,8 @@ class _LoginState extends State with SingleTickerProviderStateMixin { ), ), ); + + bool get _busy => _controller.loading; } /// Subtle text link above the footer that surfaces the currently selected diff --git a/lib/view/login/widgets/login_branding.dart b/lib/view/login/widgets/login_branding.dart index 9d6bf2a..d78e949 100644 --- a/lib/view/login/widgets/login_branding.dart +++ b/lib/view/login/widgets/login_branding.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; class LoginHeader extends StatelessWidget { - const LoginHeader({super.key}); + /// Signs in an additional account instead of the first one. + final bool addingAccount; + + const LoginHeader({this.addingAccount = false, super.key}); @override Widget build(BuildContext context) => Column( @@ -29,7 +32,10 @@ class LoginHeader extends StatelessWidget { ), const SizedBox(height: 6), Text( - 'Stundenplan, Talk, Dateien und mehr - alles an einem Ort für deinen Schulalltag am Marianum Fulda.', + addingAccount + ? 'Melde ein weiteres Konto an. In den Einstellungen kannst du ' + 'danach jederzeit zwischen deinen Konten wechseln.' + : 'Stundenplan, Talk, Dateien und mehr - alles an einem Ort für deinen Schulalltag am Marianum Fulda.', textAlign: TextAlign.center, style: TextStyle( color: Colors.white.withValues(alpha: 0.85), diff --git a/lib/view/login/widgets/login_card.dart b/lib/view/login/widgets/login_card.dart index a38c152..23703cc 100644 --- a/lib/view/login/widgets/login_card.dart +++ b/lib/view/login/widgets/login_card.dart @@ -12,9 +12,13 @@ class LoginCard extends StatefulWidget { final LoginController controller; final VoidCallback onSuccess; + /// Signs in an additional account instead of the first one. + final bool addingAccount; + const LoginCard({ required this.controller, required this.onSuccess, + this.addingAccount = false, super.key, }); @@ -83,8 +87,11 @@ class _LoginCardState extends State { return Form( key: _formKey, child: LoginCardFrame( - title: 'Anmelden', - hint: 'Melde dich mit deinen Marianum-Zugangsdaten an.', + title: widget.addingAccount ? 'Konto hinzufügen' : 'Anmelden', + hint: widget.addingAccount + ? 'Melde dich mit den Marianum-Zugangsdaten des Kontos an, das ' + 'du hinzufügen möchtest.' + : 'Melde dich mit deinen Marianum-Zugangsdaten an.', children: [ TextFormField( key: const Key('login-username-field'), @@ -127,7 +134,7 @@ class _LoginCardState extends State { const SizedBox(height: 20), LoginSubmitButton( key: const Key('login-submit-button'), - label: 'Anmelden', + label: widget.addingAccount ? 'Hinzufügen' : 'Anmelden', loading: loading, onPressed: _submit, ), diff --git a/lib/view/pages/settings/diagnostics_page.dart b/lib/view/pages/settings/diagnostics_page.dart index f67959f..695ed88 100644 --- a/lib/view/pages/settings/diagnostics_page.dart +++ b/lib/view/pages/settings/diagnostics_page.dart @@ -16,7 +16,7 @@ import '../../../extensions/date_time.dart'; import '../../../model/endpoint_data.dart'; import '../../../push/push_registration_store.dart'; import '../../../push/push_registration_type.dart'; -import '../../../session/session.dart'; +import '../../../session/account_codec.dart'; import '../../../session/session_manager.dart'; import '../../../state/app/modules/account/bloc/account_bloc.dart'; import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; @@ -103,10 +103,9 @@ class _DiagnosticsPageState extends State { title: 'Sitzung', rows: await rows({ 'Status': () async => accountStatus.name, - 'Benutzer': () async => switch (session) { - CredentialSession(:final username) => username, - null => null, - }, + 'Benutzer': () async => + session == null ? null : identityOf(session).$2, + 'Konten': () async => SessionManager().accounts.value.accounts.length, 'Demo-Modus': () async => session?.isDemo ?? false, 'Login Flow v2': () async => nextcloud?.usesLoginFlow ?? false, 'App-Passwort (Dateien)': () async => diff --git a/lib/view/pages/settings/sections/account_section.dart b/lib/view/pages/settings/sections/account_section.dart index cfb9d8b..9857480 100644 --- a/lib/view/pages/settings/sections/account_section.dart +++ b/lib/view/pages/settings/sections/account_section.dart @@ -9,7 +9,7 @@ import '../../../../routing/app_routes.dart'; import '../../../../session/session_lifecycle.dart'; import '../../../../session/session_manager.dart'; import '../../../../state/app/modules/account/bloc/account_bloc.dart'; -import '../../../../state/app/modules/account/bloc/account_state.dart'; +import '../../../../widget/account_switcher_sheet.dart'; import '../../../../widget/app_progress_indicator.dart'; import '../../../../widget/async_action_button.dart'; import '../../../../widget/avatar_actions_sheet.dart'; @@ -17,34 +17,43 @@ import '../../../../widget/confirm_dialog.dart'; import '../../../../widget/demo_restricted.dart'; import '../../../../widget/user_avatar.dart'; -// Display-name is process-wide stable until the user logs out; cache it so -// every Settings rebuild doesn't re-issue the OCS request. +// Display-name is stable per account; cache it so every Settings rebuild +// doesn't re-issue the OCS request. String? _cachedDisplayName; +String? _cachedDisplayNameFor; -class AccountSection extends StatefulWidget { +class AccountSection extends StatelessWidget { const AccountSection({super.key}); @override - State createState() => _AccountSectionState(); + Widget build(BuildContext context) => const _SchoolAccount(); } -class _AccountSectionState extends State { +class _SchoolAccount extends StatefulWidget { + const _SchoolAccount(); + + @override + State<_SchoolAccount> createState() => _SchoolAccountState(); +} + +class _SchoolAccountState extends State<_SchoolAccount> { int _avatarVersion = 0; bool _avatarBusy = false; - String? _displayName = _cachedDisplayName; + String? _displayName; @override void initState() { super.initState(); - if (_displayName == null) _loadDisplayName(); + final username = SessionManager().requireNextcloud().username; + if (_cachedDisplayNameFor == username) _displayName = _cachedDisplayName; + if (_displayName == null) _loadDisplayName(username); } - Future _loadDisplayName() async { + Future _loadDisplayName(String username) async { try { final info = await GetUserInfo().run(); - _cachedDisplayName = info.displayName.isEmpty - ? null - : info.displayName; + _cachedDisplayNameFor = username; + _cachedDisplayName = info.displayName.isEmpty ? null : info.displayName; if (!mounted) return; setState(() => _displayName = _cachedDisplayName); } catch (_) { @@ -102,7 +111,7 @@ class _AccountSectionState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( - padding: const EdgeInsets.fromLTRB(16, 20, 16, 16), + padding: const EdgeInsets.fromLTRB(16, 20, 8, 16), child: Row( children: [ SizedBox( @@ -113,8 +122,10 @@ class _AccountSectionState extends State { children: [ Center( child: GestureDetector( - onTap: () => - AppRoutes.openLargeProfilePicture(context, username), + onTap: () => AppRoutes.openLargeProfilePicture( + context, + username, + ), child: UserAvatar( key: ValueKey(_avatarVersion), id: username, @@ -134,7 +145,7 @@ class _AccountSectionState extends State { ], ), ), - const SizedBox(width: 16), + const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -167,12 +178,8 @@ class _AccountSectionState extends State { ], ), ), - const SizedBox(width: 8), - TextButton.icon( - icon: const Icon(Icons.logout_outlined, size: 18), - label: const Text('Abmelden'), - onPressed: () => _showLogoutDialog(context), - ), + const SizedBox(width: 4), + const _AccountActions(), ], ), ), @@ -183,9 +190,7 @@ class _AccountSectionState extends State { AsyncListTile( leading: const Icon(Icons.cloud_sync_outlined), title: const Text('Nextcloud neu verbinden'), - subtitle: const Text( - 'Bei Anmeldeproblemen in Talk oder Dateien', - ), + subtitle: const Text('Bei Anmeldeproblemen in Talk oder Dateien'), closeOnSuccess: false, onPressed: _reconnectNextcloud, ), @@ -204,29 +209,49 @@ class _AccountSectionState extends State { const SnackBar(content: Text('Nextcloud-Verbindung erneuert.')), ); } +} - Future _showLogoutDialog(BuildContext context) async { - // Flip AccountBloc state only after the dialog fully closes: doing it from - // inside the sign-out (the previous approach) raced AsyncDialogAction's - // pop(true) against the listener's popUntil(isFirst) and could leave the - // navigator in an inconsistent state. - final confirmed = await showDialog( - context: context, - builder: (dialogContext) => ConfirmDialog( - title: 'Abmelden?', - content: 'Möchtest du dich wirklich abmelden?', - confirmButton: 'Abmelden', - onConfirmAsync: _performLogout, +Future _confirmLogout(BuildContext context) async { + final accountBloc = context.read(); + final others = SessionManager().accounts.value.accounts.length - 1; + String? nextAccountId; + // Flip AccountBloc state only after the dialog fully closes: doing it from + // inside the sign-out (the previous approach) raced AsyncDialogAction's + // pop(true) against the navigator teardown of the account switch. + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => ConfirmDialog( + title: 'Abmelden?', + content: others > 0 + ? 'Möchtest du dich wirklich abmelden? Die App wechselt danach zu ' + 'einem deiner anderen Konten.' + : 'Möchtest du dich wirklich abmelden?', + confirmButton: 'Abmelden', + onConfirmAsync: () async => + nextAccountId = await SessionLifecycle.signOut(), + ), + ); + if (confirmed != true) return; + accountBloc.activated(nextAccountId); +} + +class _AccountActions extends StatelessWidget { + const _AccountActions(); + + @override + Widget build(BuildContext context) => Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextButton.icon( + style: compactAccountButtonStyle, + icon: const Icon(Icons.logout_outlined, size: 18), + label: const Text('Abmelden'), + onPressed: () => _confirmLogout(context), ), - ); - if (confirmed != true || !context.mounted) return; - context.read().setStatus(AccountStatus.loggedOut); - } - - Future _performLogout() async { - await SessionLifecycle.signOut(); - _cachedDisplayName = null; - } + const AccountSwitchButton(), + ], + ); } class _AvatarEditBadge extends StatelessWidget { @@ -254,11 +279,7 @@ class _AvatarEditBadge extends StatelessWidget { color: theme.colorScheme.onPrimary, ), ) - : Icon( - Icons.edit, - size: 14, - color: theme.colorScheme.onPrimary, - ), + : Icon(Icons.edit, size: 14, color: theme.colorScheme.onPrimary), ), ), ); diff --git a/lib/widget/account_switcher_sheet.dart b/lib/widget/account_switcher_sheet.dart new file mode 100644 index 0000000..21d8d8d --- /dev/null +++ b/lib/widget/account_switcher_sheet.dart @@ -0,0 +1,189 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../session/account_codec.dart'; +import '../session/session_lifecycle.dart'; +import '../session/session_manager.dart'; +import '../state/app/modules/account/bloc/account_bloc.dart'; +import '../state/app/modules/account/bloc/account_state.dart'; +import '../utils/haptics.dart'; +import 'app_progress_indicator.dart'; +import 'async_action_button.dart'; +import 'centered_leading.dart'; +import 'confirm_dialog.dart'; +import 'details_bottom_sheet.dart'; +import 'user_avatar.dart'; + +/// Lists the signed-in accounts: tap one to switch, add another, or sign out +/// of an inactive one. +Future showAccountSwitcherSheet(BuildContext context) { + final accountBloc = context.read(); + return showDetailsBottomSheet( + context, + header: Builder( + builder: (headerContext) => ListTile( + title: const Text('Konten'), + trailing: TextButton.icon( + icon: const Icon(Icons.person_add_alt_outlined, size: 18), + label: const Text('Hinzufügen'), + onPressed: () { + Navigator.pop(headerContext); + unawaited(startAddAccount(accountBloc)); + }, + ), + ), + ), + children: (sheetContext) => [ + ValueListenableBuilder( + valueListenable: SessionManager().accounts, + builder: (context, index, _) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final account in index.accounts) + _AccountTile( + account: account, + active: account.id == index.activeId, + accountBloc: accountBloc, + ), + ], + ), + ), + ], + ); +} + +/// Opens the login for another account; the active one is parked meanwhile. +Future startAddAccount(AccountBloc accountBloc) async { + await SessionLifecycle.beginAddAccount(); + accountBloc.setStatus(AccountStatus.addingAccount); +} + +/// Stacked next to the account name, which needs the width more. +/// Only the horizontal padding is trimmed; height stays at a full tap target. +final ButtonStyle compactAccountButtonStyle = TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8), +); + +/// Below the sign-out button: adds an account, or opens the switcher once +/// there is more than one. +class AccountSwitchButton extends StatelessWidget { + const AccountSwitchButton({super.key}); + + @override + Widget build(BuildContext context) => ValueListenableBuilder( + valueListenable: SessionManager().accounts, + builder: (context, index, _) => index.accounts.length > 1 + ? TextButton.icon( + style: compactAccountButtonStyle, + icon: const Icon(Icons.switch_account_outlined, size: 18), + label: const Text('Wechseln'), + onPressed: () => showAccountSwitcherSheet(context), + ) + : TextButton.icon( + style: compactAccountButtonStyle, + icon: const Icon(Icons.person_add_alt_outlined, size: 18), + label: const Text('Hinzufügen'), + onPressed: () => startAddAccount(context.read()), + ), + ); +} + +class _AccountTile extends StatefulWidget { + final AccountEntry account; + final bool active; + final AccountBloc accountBloc; + + const _AccountTile({ + required this.account, + required this.active, + required this.accountBloc, + }); + + @override + State<_AccountTile> createState() => _AccountTileState(); +} + +class _AccountTileState extends State<_AccountTile> { + bool _busy = false; + + Future _switch() async { + Haptics.selection(); + setState(() => _busy = true); + final ok = await runWithErrorDialog( + context, + () => SessionLifecycle.switchTo(widget.account.id), + ); + if (!mounted) return; + setState(() => _busy = false); + if (!ok) return; + Navigator.pop(context); + widget.accountBloc.activated(SessionManager().activeAccount?.id); + } + + void _confirmRemove() => ConfirmDialog( + title: 'Abmelden?', + content: + '${widget.account.displayName ?? widget.account.label} wird von diesem Gerät abgemeldet und seine ' + 'lokal gespeicherten Daten werden gelöscht.', + confirmButton: 'Abmelden', + onConfirmAsync: () => SessionLifecycle.removeInactive(widget.account.id), + ).asDialog(context); + + @override + Widget build(BuildContext context) { + final account = widget.account; + return ListTile( + leading: CenteredLeading( + _busy + ? const SizedBox.square( + dimension: 24, + child: AppProgressIndicator.small(), + ) + : _AccountAvatar(account: account), + ), + title: Text( + account.displayName ?? account.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + account.isDemo ? '${account.label} (Demo)' : account.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + // Sized like the IconButton so both line up. + trailing: widget.active + ? const SizedBox.square( + dimension: kMinInteractiveDimension, + child: Icon(Icons.check), + ) + : IconButton( + icon: const Icon(Icons.logout_outlined), + tooltip: 'Abmelden', + onPressed: _busy ? null : _confirmRemove, + ), + onTap: widget.active || _busy ? null : _switch, + ); + } +} + +class _AccountAvatar extends StatelessWidget { + final AccountEntry account; + + const _AccountAvatar({required this.account}); + + @override + Widget build(BuildContext context) { + // Demo accounts have no real Nextcloud user behind them. + if (!account.isDemo) return UserAvatar(id: account.label, size: 16); + final colors = Theme.of(context).colorScheme; + return CircleAvatar( + radius: 16, + backgroundColor: colors.secondaryContainer, + foregroundColor: colors.onSecondaryContainer, + child: Text(account.label.characters.first.toUpperCase()), + ); + } +} diff --git a/lib/widget/debug/bloc_storage_view.dart b/lib/widget/debug/bloc_storage_view.dart index e1a302f..e3502b1 100644 --- a/lib/widget/debug/bloc_storage_view.dart +++ b/lib/widget/debug/bloc_storage_view.dart @@ -15,30 +15,38 @@ import '../../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import '../../state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart'; import '../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../state/app/modules/timetable/bloc/timetable_bloc.dart'; +import '../../storage/account_storage.dart'; import '../../storage/hydrated_storage_bootstrap.dart'; import '../confirm_dialog.dart'; import '../placeholder_view.dart'; import 'json_viewer.dart'; -/// Lists every key persisted through [HydratedBloc.storage]. +/// Lists every key persisted by the active account's blocs and the app-wide +/// settings. class BlocStorageView extends StatefulWidget { const BlocStorageView({super.key}); - /// All persisted keys; `null` when the storage can't be enumerated. + static List get _storages => [ + HydratedBloc.storage, + AccountStorage.global, + ]; + + /// All persisted keys; `null` when a storage can't be enumerated. static List? keys() { - final storage = HydratedBloc.storage; - if (storage is InMemoryStorage) return storage.keys.toList()..sort(); - try { - // hydrated_bloc exposes no key listing; the Hive instance is the only - // way in. - // ignore: invalid_use_of_visible_for_testing_member - final box = HydratedStorage.hive.box('hydrated_box'); - return box.keys.map((k) => '$k').toList()..sort(); - } on Object { - return null; + final keys = {}; + for (final storage in _storages) { + final own = hydratedStorageKeys(storage); + if (own == null) return null; + keys.addAll(own); } + return keys.toList()..sort(); } + static Storage _storageOf(String key) => _storages.firstWhere( + (storage) => hydratedStorageKeys(storage)?.contains(key) ?? false, + orElse: () => HydratedBloc.storage, + ); + @override State createState() => _BlocStorageViewState(); } @@ -75,7 +83,7 @@ class _BlocStorageViewState extends State { final keys = BlocStorageView.keys(); setState( () => _entries = keys - ?.map((k) => _Entry(k, HydratedBloc.storage.read(k))) + ?.map((k) => _Entry(k, BlocStorageView._storageOf(k).read(k))) .toList(), ); } @@ -114,7 +122,7 @@ class _BlocStorageViewState extends State { if (reset != null) { await reset(); } else { - await HydratedBloc.storage.delete(key); + await BlocStorageView._storageOf(key).delete(key); } } diff --git a/test/api/cache_store_test.dart b/test/api/cache_store_test.dart index ae1d676..bad0123 100644 --- a/test/api/cache_store_test.dart +++ b/test/api/cache_store_test.dart @@ -18,4 +18,20 @@ void main() { expect(CacheStore.parse('abc\n{}'), isNull); }); }); + + group('CacheStore.directoryName', () { + test('keeps the original directory for the pre-multi-account account', () { + expect(CacheStore.directoryName(''), 'request_cache'); + }); + + test('separates accounts and the signed-out state', () { + final names = { + CacheStore.directoryName(''), + CacheStore.directoryName('a1b2'), + CacheStore.directoryName('c3d4'), + CacheStore.directoryName(null), + }; + expect(names, hasLength(4)); + }); + }); } diff --git a/test/session/account_codec_test.dart b/test/session/account_codec_test.dart new file mode 100644 index 0000000..ad57f54 --- /dev/null +++ b/test/session/account_codec_test.dart @@ -0,0 +1,166 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/session/account_codec.dart'; +import 'package:marianum_mobile/session/session.dart'; +import 'package:marianum_mobile/session/session_codec.dart'; + +void main() { + final school = CredentialSession(username: 'max', password: 'pw'); + final second = CredentialSession(username: 'erika', password: 'pw'); + + group('AccountIndex.activate', () { + test('adds an unknown account with its own namespace', () { + final index = AccountIndex.empty.activate(school, newId: 'a', now: 5); + expect(index.activeId, 'a'); + final entry = index.active!; + expect(entry.kind, SessionKeys.kindCredential); + expect(entry.label, 'max'); + expect(entry.namespace, 'a'); + expect(entry.lastUsed, 5); + }); + + test('legacy account keeps the un-namespaced storage', () { + final index = AccountIndex.empty.activate( + school, + newId: 'a', + namespace: '', + ); + expect(index.active!.namespace, ''); + }); + + test('a known account is reused instead of duplicated', () { + final index = AccountIndex.empty + .activate(school, newId: 'a') + .activate(second, newId: 'b') + .activate( + CredentialSession(username: 'max', password: 'new'), + newId: 'c', + now: 9, + ); + expect(index.accounts.map((e) => e.id), ['a', 'b']); + expect(index.activeId, 'a'); + expect(index.active!.lastUsed, 9); + }); + + test('demo and real account with the same name stay apart', () { + final index = AccountIndex.empty + .activate(school, newId: 'a') + .activate( + CredentialSession(username: 'max', password: 'demo', isDemo: true), + newId: 'b', + ); + expect(index.accounts, hasLength(2)); + }); + }); + + group('AccountIndex removal', () { + final index = AccountIndex.empty + .activate(school, newId: 'a', now: 1) + .activate(second, newId: 'b', now: 3) + .select('a', now: 2); + + test('removing the active account leaves none active', () { + final removed = index.remove('a'); + expect(removed.activeId, isNull); + expect(removed.accounts.map((e) => e.id), ['b']); + }); + + test('removing an inactive account keeps the active one', () { + expect(index.remove('b').activeId, 'a'); + }); + + test('takeover picks the most recently used remaining account', () { + final third = index.activate( + CredentialSession(username: 'zoe', password: 'pw'), + newId: 'c', + now: 1, + ); + expect(third.remove('a').mostRecentExcept(null)?.id, 'b'); + expect(index.mostRecentExcept('b')?.id, 'a'); + expect(AccountIndex.empty.mostRecentExcept(null), isNull); + }); + }); + + group('AccountIndex codec', () { + test('round trip', () { + final index = AccountIndex.empty + .activate(school, newId: 'a', namespace: '', now: 1) + .activate(second, newId: 'b', now: 2); + final decoded = AccountIndex.decode(index.encode()); + expect(decoded.activeId, 'b'); + expect(decoded.accounts, hasLength(2)); + expect(decoded.byId('a')!.namespace, ''); + expect(decoded.byId('b')!.label, 'erika'); + expect(decoded.byId('b')!.lastUsed, 2); + }); + + test('display name survives rename and round trip', () { + final index = AccountIndex.empty + .activate(school, newId: 'a') + .activate(second, newId: 'b') + .rename('b', 'Erika Muster'); + final decoded = AccountIndex.decode(index.encode()); + expect(decoded.byId('b')!.displayName, 'Erika Muster'); + expect(decoded.byId('a')!.displayName, isNull); + expect(decoded.activeId, 'b'); + }); + + test('garbage decodes to an empty index', () { + expect(AccountIndex.decode(null).accounts, isEmpty); + expect(AccountIndex.decode('{nope').accounts, isEmpty); + expect(AccountIndex.decode('[]').accounts, isEmpty); + }); + + test('malformed entries are skipped', () { + final decoded = AccountIndex.decode( + '{"activeId":"a","accounts":[{"id":"a"},' + '{"id":"b","kind":"credential","label":"x"}]}', + ); + expect(decoded.accounts.map((e) => e.id), ['b']); + expect(decoded.active, isNull); + }); + }); + + group('vault', () { + test('credential session survives the vault with app passwords', () { + final session = CredentialSession( + username: 'max', + password: 'pw', + appPassword: 'app', + appPasswordTalk: 'talk', + usesLoginFlow: true, + ); + final fields = decodeVault( + encodeVault({...sessionVaultFields(session), 'mc_bearer_token': 't'}), + ); + final restored = decodeSession(fields)! as CredentialSession; + expect(restored.username, 'max'); + expect(restored.password, 'pw'); + expect(restored.nextcloud.appPassword, 'app'); + expect(restored.nextcloud.appPasswordTalk, 'talk'); + expect(restored.nextcloud.usesLoginFlow, isTrue); + expect(fields['mc_bearer_token'], 't'); + }); + + test('demo session survives the vault without app passwords', () { + final fields = decodeVault( + encodeVault( + sessionVaultFields( + CredentialSession( + username: 'demo@x', + password: 'demo', + isDemo: true, + ), + ), + ), + ); + final restored = decodeSession(fields)! as CredentialSession; + expect(restored.username, 'demo@x'); + expect(restored.isDemo, isTrue); + expect(fields.containsKey(SessionKeys.appPassword), isFalse); + }); + + test('broken vault yields no session', () { + expect(decodeSession(decodeVault('garbage')), isNull); + }); + }); +} From 8eddc8f7718048612165e723062aa54feb59ee05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Sun, 27 Sep 2026 14:51:14 +0200 Subject: [PATCH 17/18] fixed widget background refresh loading custom events from the legacy backend --- lib/background/widget_background_task.dart | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/lib/background/widget_background_task.dart b/lib/background/widget_background_task.dart index 2a465bf..4e75191 100644 --- a/lib/background/widget_background_task.dart +++ b/lib/background/widget_background_task.dart @@ -6,6 +6,7 @@ import 'package:flutter/widgets.dart'; import 'package:workmanager/workmanager.dart'; import '../api/marianumconnect/marianumconnect_endpoint.dart'; +import '../api/marianumconnect/queries/timetable_custom_events/timetable_custom_events_get.dart'; import '../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays.dart'; import '../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart'; import '../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms.dart'; @@ -15,8 +16,6 @@ import '../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subj import '../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid.dart'; import '../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart'; import '../api/marianumconnect/queries/timetable_get_week/timetable_get_week.dart'; -import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event.dart'; -import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_params.dart'; import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart'; import '../session/session.dart'; import '../session/session_manager.dart'; @@ -106,7 +105,7 @@ class WidgetBackgroundTask { log('[widget-refresh] snapshot is fresh, skipping refresh'); return; } - await _refresh(session); + await _refresh(); } static Future cancelAll() async { @@ -143,7 +142,7 @@ void _callbackDispatcher() { }); } -Future _refresh(Session session) async { +Future _refresh() async { await WidgetSync.ensureInitialized(); // The background isolate doesn't go through main.dart's BlocBuilder, so we // re-apply the endpoint the foreground last persisted. Without this the @@ -182,14 +181,9 @@ Future _refresh(Session session) async { final timegridFuture = _runOrNull( () => TimetableGetTimegrid().run(), ); - final customEventsFuture = switch (session) { - CredentialSession(:final legacyUserSecret) => - _runOrNull( - () => GetCustomTimetableEvent( - GetCustomTimetableEventParams(legacyUserSecret), - ).run(), - ), - }; + final customEventsFuture = _runOrNull( + () => TimetableCustomEventsGet().run(), + ); final timetable = await timetableFuture; final subjects = await subjectsFuture; final rooms = await roomsFuture; From 3e856d0b1cbe3c0abba9cf3a29a21e7a498d6a9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Sun, 27 Sep 2026 14:57:49 +0200 Subject: [PATCH 18/18] updated project notes to the current backends --- CLAUDE.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9e4ba53..3d2c664 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # MarianumMobile Client -Flutter-App für die Schul-Community: Webuntis-Stundenplan, Nextcloud Talk + Files, Custom MHSL-Backend (Breaker, Custom Events, Push). +Flutter-App für die Schul-Community: Stundenplan, Ticker, Newsletter & Co. über das MarianumConnect-Backend, Nextcloud Talk + Files. ## Stack @@ -16,7 +16,7 @@ Flutter-App für die Schul-Community: Webuntis-Stundenplan, Nextcloud Talk + Fil ``` lib/ -├── api/ HTTP-Layer pro Backend (mhsl/, marianumcloud/, webuntis/, holidays/) +├── api/ HTTP-Layer pro Backend (marianumconnect/, marianumcloud/, mhsl/ Legacy, demo/) ├── session/ Session-Modell, SessionManager, SessionLifecycle, Konten-Index ├── state/app/modules/ BLoC pro Feature-Modul (timetable, chat, chat_list, files, ...) ├── state/app/infrastructure LoadableState, DataLoader, geteilte BLoC-Bausteine @@ -70,13 +70,12 @@ flutter test # Tests (siehe test | Backend | Pfad | Zweck | |---------------------------|-----------------------|----------------------------------------| -| Webuntis | `lib/api/webuntis/` | Stundenplan, Klassen, Räume, Lehrer | +| MarianumConnect (Bearer) | `lib/api/marianumconnect/` | Auth, Stundenplan (Webuntis-Proxy), Ticker, Newsletter, Ferien, Abwesenheit, Capabilities, Push | | Nextcloud (Talk + WebDAV) | `lib/api/marianumcloud/` | Chats, Datei-Verwaltung | -| Custom MHSL-Server | `lib/api/mhsl/` | Breaker, Custom Events, Notify, Noten | -| Holiday-Calendar | `lib/api/holidays/` | Ferien | +| MHSL (Legacy) | `lib/api/mhsl/` | nur noch Einmal-Migration der Custom Events | `nextcloud`-Paket ist auf einen Custom-Fork gepinnt (siehe `pubspec.yaml` `dependency_overrides`). ## Tests -`test/` deckt aktuell nur Kern-Funktionen ab (DateTime-Extensions, AsyncActionController, LessonResolver). Beim Hinzufügen neuer pure-function-Helper bitte Test mit dazu. +`test/` deckt vor allem pure Funktionen ab (DateTime-Extensions, Stundenplan-Logik, Session-Codec). Beim Hinzufügen neuer pure-function-Helper bitte Test mit dazu.