From 67c935c05be0726b3add031e1ddcea8599d40812 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Sun, 20 Sep 2026 11:11:58 +0200 Subject: [PATCH] added guardian login with views for their assigned childs --- CLAUDE.md | 20 +- android/app/src/main/AndroidManifest.xml | 17 + integration_test/screenshot_test.dart | 4 +- ios/Runner/Info.plist | 2 + ios/Runner/Runner.entitlements | 5 + lib/access/access_requirement.dart | 15 + lib/access/user_role.dart | 20 ++ lib/api/demo/data/demo_capabilities.dart | 23 ++ lib/api/demo/data/demo_talk.dart | 7 +- lib/api/demo/demo_mode.dart | 14 +- 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 | 14 +- .../auth/auth_interceptor.dart | 16 +- .../auth/session_validator.dart | 39 +- .../marianumconnect/auth/token_storage.dart | 17 +- .../queries/absence/absence_prefill.dart | 9 +- .../queries/absence/absence_submit.dart | 5 +- .../auth_guardian/auth_guardian_request.dart | 58 +++ .../auth_guardian/auth_guardian_verify.dart | 51 +++ .../guardian_login_exception.dart | 126 +++++++ .../queries/auth_me/auth_me.dart | 30 ++ .../queries/auth_verify/auth_verify.dart | 9 +- .../get_capabilities_response.dart | 11 +- .../get_capabilities_response.g.dart | 6 + .../get_capabilities/guardian_child.dart | 23 ++ .../guardian_child.freezed.dart | 294 ++++++++++++++++ .../get_capabilities/guardian_child.g.dart | 23 ++ .../push_device_register.dart | 17 +- .../telemetry_device_id.dart | 12 +- .../custom_events_migration.dart | 15 +- .../timetable_get_child_week.dart | 18 + lib/app.dart | 91 +++-- lib/auth_link/device_binding.dart | 22 ++ lib/auth_link/guardian_link_listener.dart | 45 +++ lib/auth_link/guardian_login_link.dart | 26 ++ lib/auth_link/pending_guardian_request.dart | 83 +++++ lib/background/widget_background_task.dart | 75 ++-- lib/main.dart | 146 +++++--- lib/model/account_data.dart | 333 ------------------ lib/notification/notification_tasks.dart | 5 + lib/push/direct_push_registration.dart | 71 ++++ lib/push/push_actions.dart | 18 +- lib/push/push_device_info.dart | 15 + lib/push/push_registration.dart | 70 ++-- 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 | 74 ++++ lib/session/session_codec.dart | 70 ++++ lib/session/session_lifecycle.dart | 25 ++ lib/session/session_manager.dart | 248 +++++++++++++ .../loadable_hydrated_bloc.dart | 10 + lib/state/app/modules/app_modules.dart | 20 +- .../capabilities/bloc/capabilities_cubit.dart | 13 +- .../capabilities/bloc/capabilities_state.dart | 11 +- .../bloc/capabilities_state.freezed.dart | 75 ++-- .../bloc/capabilities_state.g.dart | 6 + .../chat_list/bloc/chat_list_bloc.dart | 7 + .../children/child_selection_cubit.dart | 33 ++ .../bloc/foreign_timetable_bloc.dart | 204 ----------- .../foreign_timetable_data_provider.dart | 64 ---- .../foreign_timetable_repository.dart | 12 - .../bloc/nextcloud_capabilities_cubit.dart | 2 + .../timetable/bloc/timetable_bloc.dart | 57 ++- .../timetable/bloc/timetable_state.dart | 7 +- .../timetable_data_provider.dart | 32 +- .../timetable/policy/timetable_policy.dart | 54 +++ .../primary/primary_subject_resolver.dart | 18 + .../primary/primary_timetable_scope.dart | 81 +++++ .../timetable/subject/timetable_subject.dart | 110 ++++++ lib/utils/downloads/download_manager.dart | 4 +- lib/utils/random_id.dart | 10 + lib/view/login/account_loading_screen.dart | 4 +- lib/view/login/guardian_login_controller.dart | 196 +++++++++++ lib/view/login/login.dart | 88 ++++- lib/view/login/login_controller.dart | 41 ++- lib/view/login/nextcloud_login_flow_page.dart | 10 +- .../login/widgets/guardian_login_card.dart | 225 ++++++++++++ .../login/widgets/login_audience_card.dart | 63 ++++ 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 ++++++ .../absence_report/absence_form_policy.dart | 28 ++ .../absence_report/absence_report_view.dart | 61 +++- .../pages/files/widgets/file_leading.dart | 4 +- .../widgets/event_list_tile.dart | 41 ++- .../settings/sections/account_section.dart | 133 ++++--- .../sections/notifications_section.dart | 205 +++++++++++ .../pages/settings/sections/talk_section.dart | 180 ---------- lib/view/pages/settings/settings.dart | 54 +-- 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 +- .../pages/talk/widgets/user_search_tile.dart | 18 +- lib/view/pages/timetable/timetable.dart | 195 +++++----- lib/widget/child_switcher.dart | 85 +++++ .../file_viewer/unknown_preview_block.dart | 4 +- lib/widget/user_avatar.dart | 5 +- lib/widget_data/widget_publisher.dart | 11 +- lib/widget_data/widget_sync.dart | 52 ++- pubspec.yaml | 3 + test/access/user_role_test.dart | 28 ++ test/auth_link/guardian_login_link_test.dart | 107 ++++++ test/push/direct_push_registration_test.dart | 46 +++ test/session/session_codec_test.dart | 134 +++++++ test/state/app_modules_order_test.dart | 27 +- test/state/primary_subject_test.dart | 169 +++++++++ test/state/timetable_subject_policy_test.dart | 133 +++++++ .../login/guardian_login_controller_test.dart | 301 ++++++++++++++++ 117 files changed, 4784 insertions(+), 1514 deletions(-) create mode 100644 lib/access/access_requirement.dart create mode 100644 lib/access/user_role.dart create mode 100644 lib/api/marianumconnect/queries/auth_guardian/auth_guardian_request.dart create mode 100644 lib/api/marianumconnect/queries/auth_guardian/auth_guardian_verify.dart create mode 100644 lib/api/marianumconnect/queries/auth_guardian/guardian_login_exception.dart create mode 100644 lib/api/marianumconnect/queries/auth_me/auth_me.dart create mode 100644 lib/api/marianumconnect/queries/get_capabilities/guardian_child.dart create mode 100644 lib/api/marianumconnect/queries/get_capabilities/guardian_child.freezed.dart create mode 100644 lib/api/marianumconnect/queries/get_capabilities/guardian_child.g.dart create mode 100644 lib/api/marianumconnect/queries/timetable_get_child_week/timetable_get_child_week.dart create mode 100644 lib/auth_link/device_binding.dart create mode 100644 lib/auth_link/guardian_link_listener.dart create mode 100644 lib/auth_link/guardian_login_link.dart create mode 100644 lib/auth_link/pending_guardian_request.dart delete mode 100644 lib/model/account_data.dart create mode 100644 lib/push/direct_push_registration.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/state/app/modules/children/child_selection_cubit.dart delete mode 100644 lib/state/app/modules/foreign_timetable/bloc/foreign_timetable_bloc.dart delete mode 100644 lib/state/app/modules/foreign_timetable/data_provider/foreign_timetable_data_provider.dart delete mode 100644 lib/state/app/modules/foreign_timetable/repository/foreign_timetable_repository.dart create mode 100644 lib/state/app/modules/timetable/policy/timetable_policy.dart create mode 100644 lib/state/app/modules/timetable/primary/primary_subject_resolver.dart create mode 100644 lib/state/app/modules/timetable/primary/primary_timetable_scope.dart create mode 100644 lib/state/app/modules/timetable/subject/timetable_subject.dart create mode 100644 lib/utils/random_id.dart create mode 100644 lib/view/login/guardian_login_controller.dart create mode 100644 lib/view/login/widgets/guardian_login_card.dart create mode 100644 lib/view/login/widgets/login_audience_card.dart create mode 100644 lib/view/login/widgets/login_form_parts.dart create mode 100644 lib/view/pages/absence_report/absence_form_policy.dart create mode 100644 lib/view/pages/settings/sections/notifications_section.dart create mode 100644 lib/widget/child_switcher.dart create mode 100644 test/access/user_role_test.dart create mode 100644 test/auth_link/guardian_login_link_test.dart create mode 100644 test/push/direct_push_registration_test.dart create mode 100644 test/session/session_codec_test.dart create mode 100644 test/state/primary_subject_test.dart create mode 100644 test/state/timetable_subject_policy_test.dart create mode 100644 test/view/login/guardian_login_controller_test.dart diff --git a/CLAUDE.md b/CLAUDE.md index 5202b16..4d5ac32 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. Zwei Kontoarten: Schul-Konto (Schüler/Lehrer, Benutzername + Passwort) und Eltern-Konto (passwortlos per E-Mail-Code/App-Link, sieht die Stundenpläne der zugeordneten Kinder, kein Talk/Files). ## Stack @@ -16,7 +16,10 @@ 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 (CredentialSession / GuardianSession), SessionManager, SessionLifecycle +├── access/ UserRole, AccessRequirement (Gating von Modulen/Settings) +├── auth_link/ Eltern-Login: App-Link-Listener, Link-Parser, Geräte-Bindung ├── state/app/modules/ BLoC pro Feature-Modul (timetable, chat, chat_list, files, ...) ├── state/app/infrastructure LoadableState, DataLoader, geteilte BLoC-Bausteine ├── view/ Screens @@ -51,6 +54,12 @@ 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()` – Eltern-Sessions haben keine Nextcloud-Identität. Abmelden ausschließlich über `SessionLifecycle.signOut()`. Die Keychain-Keys in `SessionKeys` sind eingefroren (Bestandsinstallationen, iOS-NSE). + +**Rollen & Zuschnitt:** Views verzweigen nie auf Rollen. Module und Settings-Sections deklarieren `AccessRequirement`s (`AppModule.requirements`, `Settings._sections`); Views bekommen ein Subjekt + eine Policy, die an genau einer Stelle als pure Funktion aufgelöst wird (Vorbild: `TimetableSubject` + `TimetablePolicy.resolve`, `AbsenceFormPolicy`). `UserRole` nur für Anzeige/Policy-Ableitung. + +**Stundenplan:** Ein `TimetableBloc(subject: …)` für alle Fälle (eigener Plan, Fremdplan, Kind). Den globalen Bloc stellt `PrimaryTimetableScope` bereit und tauscht ihn beim Kindwechsel aus; page-scoped Fremdpläne nutzen `ScopedTimetableBloc`. Die Kinderauswahl (`ChildSelectionCubit`) ist modulübergreifend. + ## Build / Run ```bash @@ -65,13 +74,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, Policies, Eltern-Login-Controller). Beim Hinzufügen neuer pure-function-Helper bitte Test mit dazu. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 3800d0c..dba5873 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -41,6 +41,23 @@ + + + + + + + + + + + + diff --git a/integration_test/screenshot_test.dart b/integration_test/screenshot_test.dart index d467b96..f9e3a57 100644 --- a/integration_test/screenshot_test.dart +++ b/integration_test/screenshot_test.dart @@ -106,12 +106,14 @@ void _log(String message) => debugPrint('SHOTS: $message'); Future _login(WidgetTester tester) async { final loginVisible = await _pumpUntil( tester, - find.byKey(const Key('login-username-field')), + find.byKey(const Key('login-audience-school')), ); if (!loginVisible) { _log('kein Login-Screen sichtbar – bereits angemeldet, überspringe Login'); return; } + await tester.tap(find.byKey(const Key('login-audience-school'))); + await _pumpUntil(tester, find.byKey(const Key('login-username-field'))); await tester.enterText( find.byKey(const Key('login-username-field')), 'demo@screenshots', diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 09dcffc..10d8b40 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -37,6 +37,8 @@ CFBundleVersion $(FLUTTER_BUILD_NUMBER) + FlutterDeepLinkingEnabled + LSRequiresIPhoneOS NSCameraUsageDescription diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements index 275fdd2..ccd9f09 100644 --- a/ios/Runner/Runner.entitlements +++ b/ios/Runner/Runner.entitlements @@ -4,6 +4,11 @@ aps-environment development + com.apple.developer.associated-domains + + applinks:connect.marianum-fulda.de + applinks:connect-beta.marianum-fulda.de + com.apple.security.application-groups group.eu.mhsl.marianum.mobile.client.widget diff --git a/lib/access/access_requirement.dart b/lib/access/access_requirement.dart new file mode 100644 index 0000000..8e0e1b5 --- /dev/null +++ b/lib/access/access_requirement.dart @@ -0,0 +1,15 @@ +import '../session/session.dart'; + +/// Backend identity a feature needs. Modules and settings sections declare +/// these; anything whose requirements the session does not meet is hidden. +enum AccessRequirement { + nextcloud; + + bool isMetBy(Session? session) => switch (this) { + AccessRequirement.nextcloud => session?.nextcloud != null, + }; +} + +extension AccessRequirements on Set { + bool areMetBy(Session? session) => every((r) => r.isMetBy(session)); +} diff --git a/lib/access/user_role.dart b/lib/access/user_role.dart new file mode 100644 index 0000000..c0e5769 --- /dev/null +++ b/lib/access/user_role.dart @@ -0,0 +1,20 @@ +/// Account role as reported by MarianumConnect. Only used for display and for +/// deriving policies; features gate on capabilities and requirements instead +/// of comparing roles. +enum UserRole { + student, + teacher, + staff, + parent, + unknown; + + /// Unknown or missing values map to [unknown] so a new server-side role + /// never breaks older app versions. + static UserRole parse(String? wire) => switch (wire) { + 'STUDENT' => UserRole.student, + 'TEACHER' => UserRole.teacher, + 'STAFF' => UserRole.staff, + 'PARENT' => UserRole.parent, + _ => UserRole.unknown, + }; +} diff --git a/lib/api/demo/data/demo_capabilities.dart b/lib/api/demo/data/demo_capabilities.dart index 305f112..a0291d3 100644 --- a/lib/api/demo/data/demo_capabilities.dart +++ b/lib/api/demo/data/demo_capabilities.dart @@ -1,5 +1,7 @@ import '../../../state/app/modules/capabilities/bloc/capabilities_state.dart'; import '../../../state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_state.dart'; +import '../../marianumconnect/queries/get_capabilities/guardian_child.dart'; +import '../demo_persona.dart'; /// Demo fixtures for the mobile capability flags — everything granted so the /// demo persona sees every feature (incl. push) as available and the timetable @@ -17,6 +19,27 @@ class DemoCapabilities { userType: 'STUDENT', loaded: true, ); + + /// Guardian persona with two children, so the child switcher is visible. + static CapabilitiesState guardianState() => const CapabilitiesState( + pushNotifications: true, + userType: 'PARENT', + children: [ + GuardianChild( + id: 'demo-child-1', + firstName: DemoPersona.studentFirstName, + lastName: 'Hoffmann', + className: DemoPersona.className, + ), + GuardianChild( + id: 'demo-child-2', + firstName: 'Jonas', + lastName: 'Hoffmann', + className: '6a', + ), + ], + loaded: true, + ); } /// Demo fixtures for the Nextcloud `files_sharing` capabilities — a permissive 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..e08f638 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 { @@ -22,6 +22,14 @@ class DemoMode { static bool matches(String username) => username.trim().toLowerCase().startsWith(usernamePrefix); + /// Guardian logins are passwordless, so reviewers need a fixed address that + /// skips the mail round-trip. Deliberately not the `demo@` prefix, which a + /// real e-mail address could start with. + static const String guardianEmail = 'demo-eltern@marianum-fulda.de'; + + static bool matchesGuardian(String email) => + email.trim().toLowerCase() == guardianEmail; + /// 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 942b560..9b79797 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 { @@ -18,7 +18,7 @@ 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(); + final secret = SessionManager().requireNextcloud().secret; if (_webdav == null || _webdavSecret != secret) { _webdavSecret = secret; _webdav = establishWebdavConnection(); @@ -30,13 +30,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 b78ac97..90338f5 100644 --- a/lib/api/marianumconnect/auth/auth_interceptor.dart +++ b/lib/api/marianumconnect/auth/auth_interceptor.dart @@ -1,12 +1,14 @@ 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. Only password accounts can renew +/// silently; passwordless accounts surface the 401. class MarianumConnectAuthInterceptor extends Interceptor { static const _retriedKey = 'mc_auth_retried'; @@ -64,6 +66,9 @@ class MarianumConnectAuthInterceptor extends Interceptor { } final refreshed = await _attemptReLogin(); if (!refreshed) { + if (SessionManager().current is GuardianSession) { + SessionManager().reportUnauthorized(); + } handler.next(err); return; } @@ -87,11 +92,12 @@ class MarianumConnectAuthInterceptor extends Interceptor { } Future _performReLogin() async { - if (!AccountData().isPopulated()) return false; + final session = SessionManager().current; + if (session is! CredentialSession) return false; try { await _loginClient.run( - username: AccountData().getUsername(), - password: AccountData().getPassword(), + username: session.username, + password: session.password, tokenName: await DeviceTokenName.resolve(), ); return true; diff --git a/lib/api/marianumconnect/auth/session_validator.dart b/lib/api/marianumconnect/auth/session_validator.dart index 4280b4b..7a66392 100644 --- a/lib/api/marianumconnect/auth/session_validator.dart +++ b/lib/api/marianumconnect/auth/session_validator.dart @@ -1,35 +1,38 @@ 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_me/auth_me.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. For password accounts a server-side password rotation +/// forces a re-login on the next cold start even when the bearer token would +/// still be accepted; for guardians it confirms a rejected token before the +/// session is dropped. 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 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; try { - await AuthVerify().run(username: username, password: password); + switch (session) { + case CredentialSession(:final username, :final password): + await AuthVerify().run(username: username, password: password); + case GuardianSession(): + await AuthMe().run(); + } } on AuthException catch (e) { if (e.statusCode != 401) return; - log('MC: stored credentials rejected — forcing re-login'); - await AuthLogout().run(); - await const MarianumConnectTokenStorage().clear(); - await AccountData().removeData(); + log('MC: stored session rejected — forcing re-login'); + await SessionLifecycle.signOut(); 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/queries/absence/absence_prefill.dart b/lib/api/marianumconnect/queries/absence/absence_prefill.dart index 0d7fb61..0f4e901 100644 --- a/lib/api/marianumconnect/queries/absence/absence_prefill.dart +++ b/lib/api/marianumconnect/queries/absence/absence_prefill.dart @@ -2,9 +2,14 @@ import '../../marianumconnect_query.dart'; import 'absence_prefill_response.dart'; /// GETs the absence-report form prefill (`absence/prefill`, bearer-auth). +/// Guardians pass the [childId] the report is for; the identity then comes +/// from that child. class AbsencePrefill extends MarianumConnectQuery { AbsencePrefill({super.dio}); - Future run() => - getObject('absence/prefill', AbsencePrefillResponse.fromJson); + Future run({String? childId}) => getObject( + 'absence/prefill', + AbsencePrefillResponse.fromJson, + queryParameters: {'childId': ?childId}, + ); } diff --git a/lib/api/marianumconnect/queries/absence/absence_submit.dart b/lib/api/marianumconnect/queries/absence/absence_submit.dart index 8f8c874..5f3bde3 100644 --- a/lib/api/marianumconnect/queries/absence/absence_submit.dart +++ b/lib/api/marianumconnect/queries/absence/absence_submit.dart @@ -3,7 +3,8 @@ import '../../marianumconnect_query.dart'; /// Submits an absence report (`POST absence`, bearer-auth, `source=mobile`). /// Empty identity fields are backfilled from LDAP server-side; validation /// (all fields required, class must exist, no past start date, end >= start) -/// also runs server-side and mirrors the client checks. +/// also runs server-side and mirrors the client checks. For guardians the +/// server takes name and class from the child given by [childId]. class AbsenceSubmit extends MarianumConnectQuery { AbsenceSubmit({super.dio}); @@ -15,6 +16,7 @@ class AbsenceSubmit extends MarianumConnectQuery { required DateTime absentUntil, required String phone, required String note, + String? childId, }) => guard(() async { await dio.post( endpoint('absence'), @@ -26,6 +28,7 @@ class AbsenceSubmit extends MarianumConnectQuery { 'absentUntil': isoDate(absentUntil), 'phone': phone, 'note': note, + 'childId': ?childId, }, ); }); diff --git a/lib/api/marianumconnect/queries/auth_guardian/auth_guardian_request.dart b/lib/api/marianumconnect/queries/auth_guardian/auth_guardian_request.dart new file mode 100644 index 0000000..57adf85 --- /dev/null +++ b/lib/api/marianumconnect/queries/auth_guardian/auth_guardian_request.dart @@ -0,0 +1,58 @@ +import 'package:dio/dio.dart'; + +import '../../../../auth_link/pending_guardian_request.dart'; +import '../../marianumconnect_api.dart'; +import '../../marianumconnect_query.dart'; +import 'guardian_login_exception.dart'; + +class AuthGuardianRequestResponse { + final String requestId; + final DateTime expiresAt; + final DateTime resendAvailableAt; + final int codeLength; + + const AuthGuardianRequestResponse({ + required this.requestId, + required this.expiresAt, + required this.resendAvailableAt, + required this.codeLength, + }); + + factory AuthGuardianRequestResponse.fromJson(Map json) => + AuthGuardianRequestResponse( + requestId: json['requestId'] as String, + expiresAt: DateTime.parse(json['expiresAt'] as String), + resendAvailableAt: DateTime.parse(json['resendAvailableAt'] as String), + codeLength: + json['codeLength'] as int? ?? + PendingGuardianRequest.defaultCodeLength, + ); +} + +/// Starts a passwordless guardian login: the server mails a code and a link. +/// The answer is identical for unknown addresses, so it reveals nothing about +/// which e-mails are registered. +class AuthGuardianRequest extends MarianumConnectQuery { + AuthGuardianRequest({Dio? dio}) + : super(dio: dio ?? MarianumConnectApi.plainDio()); + + Future run({ + required String email, + required String deviceChallenge, + required String tokenName, + }) async { + try { + final response = await dio.post>( + endpoint('auth/guardian/request'), + data: { + 'email': email, + 'deviceChallenge': deviceChallenge, + 'tokenName': tokenName, + }, + ); + return AuthGuardianRequestResponse.fromJson(response.data!); + } on DioException catch (e) { + throw GuardianLoginException.fromDio(e); + } + } +} diff --git a/lib/api/marianumconnect/queries/auth_guardian/auth_guardian_verify.dart b/lib/api/marianumconnect/queries/auth_guardian/auth_guardian_verify.dart new file mode 100644 index 0000000..5781a78 --- /dev/null +++ b/lib/api/marianumconnect/queries/auth_guardian/auth_guardian_verify.dart @@ -0,0 +1,51 @@ +import 'package:dio/dio.dart'; + +import '../../auth/token_storage.dart'; +import '../../marianumconnect_api.dart'; +import '../../marianumconnect_query.dart'; +import '../auth_login/auth_login_response.dart'; +import 'guardian_login_exception.dart'; + +/// Completes a guardian login with the mailed code or link token and stores +/// the issued bearer token. +class AuthGuardianVerify extends MarianumConnectQuery { + final MarianumConnectTokenStorage _tokenStorage; + + AuthGuardianVerify({ + MarianumConnectTokenStorage tokenStorage = + const MarianumConnectTokenStorage(), + Dio? dio, + }) : _tokenStorage = tokenStorage, + super(dio: dio ?? MarianumConnectApi.plainDio()); + + Future run({ + required String requestId, + required String deviceVerifier, + required String tokenName, + String? code, + String? linkToken, + }) async { + assert((code == null) != (linkToken == null)); + try { + final response = await dio.post>( + endpoint('auth/guardian/verify'), + data: { + 'requestId': requestId, + 'deviceVerifier': deviceVerifier, + 'tokenName': tokenName, + 'code': ?code, + 'linkToken': ?linkToken, + }, + ); + final payload = AuthLoginResponse.fromJson(response.data!); + await _tokenStorage.write( + token: payload.token, + tokenId: payload.tokenId, + expiresAt: payload.expiresAt, + ); + return payload; + } on DioException catch (e) { + throw GuardianLoginException.fromDio(e); + } + } +} diff --git a/lib/api/marianumconnect/queries/auth_guardian/guardian_login_exception.dart b/lib/api/marianumconnect/queries/auth_guardian/guardian_login_exception.dart new file mode 100644 index 0000000..672741d --- /dev/null +++ b/lib/api/marianumconnect/queries/auth_guardian/guardian_login_exception.dart @@ -0,0 +1,126 @@ +import 'package:dio/dio.dart'; + +import '../../../errors/app_exception.dart'; +import '../../errors/marianumconnect_error.dart'; + +enum GuardianLoginError { + invalidRequest, + emailNotRegistered, + accountDisabled, + invalidCode, + deviceMismatch, + requestConsumed, + requestExpired, + tooManyAttempts, + rateLimited, + unsupportedServer, +} + +/// A rejected guardian login step with the reason the server reported. +class GuardianLoginException extends AppException { + final GuardianLoginError error; + final int? attemptsLeft; + + const GuardianLoginException( + this.error, { + required super.userMessage, + this.attemptsLeft, + super.technicalDetails, + }) : super(allowRetry: false); + + /// Maps a failed guardian auth call. Only 4xx answers that carry a guardian + /// login reason become [GuardianLoginException]; everything else keeps the + /// generic MarianumConnect mapping (network, 5xx, …). + static AppException fromDio(DioException e) { + final response = e.response; + final status = response?.statusCode; + if (status == null || status < 400 || status >= 500) { + return mapMarianumConnectError(e); + } + final (code, attemptsLeft) = _parseBody(response!.data); + final error = _errorFor(code, status); + if (error == null) return mapMarianumConnectError(e); + return GuardianLoginException( + error, + attemptsLeft: attemptsLeft, + userMessage: messageFor(error, attemptsLeft: attemptsLeft), + technicalDetails: 'MC $status: ${response.data}', + ); + } + + static String messageFor( + GuardianLoginError error, { + int? attemptsLeft, + }) => switch (error) { + GuardianLoginError.invalidRequest => + 'Bitte gib eine gültige E-Mail-Adresse ein.', + GuardianLoginError.emailNotRegistered => + 'Unter dieser E-Mail-Adresse ist kein Eltern-Zugang hinterlegt. ' + 'Bitte prüfe, ob die korrekte Adresse verwendet wurde. Bitte wende dich an das Sekretariat, ' + 'wenn du nicht weißt, welche Adresse für dich registriert ist.', + GuardianLoginError.accountDisabled => + 'Dieser Eltern-Zugang ist derzeit deaktiviert. Bitte wende dich an ' + 'das Sekretariat.', + GuardianLoginError.invalidCode => + attemptsLeft == null + ? 'Der Code ist falsch.' + : 'Der Code ist falsch. Noch $attemptsLeft ' + '${attemptsLeft == 1 ? 'Versuch' : 'Versuche'}.', + GuardianLoginError.deviceMismatch => + 'Dieser Anmeldelink wurde auf einem anderen Gerät angefordert. ' + 'Bitte gib stattdessen den Code aus der E-Mail ein.', + GuardianLoginError.requestConsumed => + 'Diese Anmeldung wurde bereits verwendet. Bitte fordere einen neuen ' + 'Code an.', + GuardianLoginError.requestExpired => + 'Der Code ist abgelaufen. Bitte fordere einen neuen an.', + GuardianLoginError.tooManyAttempts => + 'Zu viele Fehlversuche. Bitte fordere einen neuen Code an.', + GuardianLoginError.rateLimited => + 'Zu viele Anfragen. Bitte warte einige Stunden und versuche es ' + 'erneut.', + GuardianLoginError.unsupportedServer => + 'Die Eltern-Anmeldung ist auf diesem Server noch nicht verfügbar. ' + 'Bitte versuche es später erneut.', + }; + + /// Accepts the documented JSON body (`{"error": …, "attemptsLeft": …}`) as + /// well as the plain-text `Fehler: ` the server's generic error + /// handler produces. + static (String?, int?) _parseBody(Object? data) { + if (data is Map) { + final attempts = data['attemptsLeft']; + return (data['error'] as String?, attempts is int ? attempts : null); + } + if (data is String) { + final code = data.startsWith('Fehler: ') + ? data.substring('Fehler: '.length) + : data; + return (code.trim(), null); + } + return (null, null); + } + + static GuardianLoginError? _errorFor(String? code, int status) => + switch (code) { + 'invalid_request' => GuardianLoginError.invalidRequest, + 'email_not_registered' => GuardianLoginError.emailNotRegistered, + 'account_disabled' => GuardianLoginError.accountDisabled, + 'invalid_code' => GuardianLoginError.invalidCode, + 'device_mismatch' => GuardianLoginError.deviceMismatch, + 'request_consumed' => GuardianLoginError.requestConsumed, + 'request_expired' => GuardianLoginError.requestExpired, + 'too_many_attempts' => GuardianLoginError.tooManyAttempts, + _ => switch (status) { + 401 => GuardianLoginError.invalidCode, + // The server names an unknown address explicitly + // (`email_not_registered`); a bare 404/405 means the endpoint itself + // is missing, i.e. a server version without guardian login. + 404 || 405 => GuardianLoginError.unsupportedServer, + 409 => GuardianLoginError.requestConsumed, + 410 => GuardianLoginError.requestExpired, + 429 => GuardianLoginError.rateLimited, + _ => null, + }, + }; +} 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..2fe75ba --- /dev/null +++ b/lib/api/marianumconnect/queries/auth_me/auth_me.dart @@ -0,0 +1,30 @@ +import 'package:dio/dio.dart'; + +import '../../../errors/auth_exception.dart'; +import '../../auth/token_storage.dart'; +import '../../marianumconnect_api.dart'; +import '../../marianumconnect_query.dart'; + +/// Probes that the stored bearer token is still accepted. Used for accounts +/// without a password (guardians), whose token cannot be renewed silently. +/// +/// 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 { + final options = await _tokenStorage.requireBearerOptions('AuthMe'); + return guard(() async { + await dio.get(endpoint('auth/me'), options: options); + }); + } +} 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_capabilities/get_capabilities_response.dart b/lib/api/marianumconnect/queries/get_capabilities/get_capabilities_response.dart index deda1df..82cd594 100644 --- a/lib/api/marianumconnect/queries/get_capabilities/get_capabilities_response.dart +++ b/lib/api/marianumconnect/queries/get_capabilities/get_capabilities_response.dart @@ -1,5 +1,7 @@ import 'package:json_annotation/json_annotation.dart'; +import 'guardian_child.dart'; + part 'get_capabilities_response.g.dart'; /// Slimmed-down capability flags the mobile UI gates features on. The backend @@ -23,16 +25,21 @@ class CapabilitiesResponse { final int? timetableFutureDays; - /// LDAP user type ('TEACHER' | 'STUDENT' | 'STAFF'). Null when the backend - /// predates the field or has no LDAP record for the user. + /// User type ('TEACHER' | 'STUDENT' | 'STAFF' | 'PARENT'). Null when the + /// backend predates the field or has no record for the user. final String? userType; + /// Students linked to a guardian account; empty for everyone else. + @JsonKey(defaultValue: []) + final List children; + CapabilitiesResponse({ required this.viewForeignTimetables, required this.pushNotifications, this.timetablePastDays, this.timetableFutureDays, this.userType, + this.children = const [], }); factory CapabilitiesResponse.fromJson(Map json) => diff --git a/lib/api/marianumconnect/queries/get_capabilities/get_capabilities_response.g.dart b/lib/api/marianumconnect/queries/get_capabilities/get_capabilities_response.g.dart index 2f5ac3e..5eca61a 100644 --- a/lib/api/marianumconnect/queries/get_capabilities/get_capabilities_response.g.dart +++ b/lib/api/marianumconnect/queries/get_capabilities/get_capabilities_response.g.dart @@ -14,6 +14,11 @@ CapabilitiesResponse _$CapabilitiesResponseFromJson( timetablePastDays: (json['timetablePastDays'] as num?)?.toInt(), timetableFutureDays: (json['timetableFutureDays'] as num?)?.toInt(), userType: json['userType'] as String?, + children: + (json['children'] as List?) + ?.map((e) => GuardianChild.fromJson(e as Map)) + .toList() ?? + [], ); Map _$CapabilitiesResponseToJson( @@ -24,4 +29,5 @@ Map _$CapabilitiesResponseToJson( 'timetablePastDays': instance.timetablePastDays, 'timetableFutureDays': instance.timetableFutureDays, 'userType': instance.userType, + 'children': instance.children, }; diff --git a/lib/api/marianumconnect/queries/get_capabilities/guardian_child.dart b/lib/api/marianumconnect/queries/get_capabilities/guardian_child.dart new file mode 100644 index 0000000..a9f4337 --- /dev/null +++ b/lib/api/marianumconnect/queries/get_capabilities/guardian_child.dart @@ -0,0 +1,23 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'guardian_child.freezed.dart'; +part 'guardian_child.g.dart'; + +/// A student linked to the signed-in guardian. [id] is an opaque server id, +/// not a WebUntis id. +@freezed +abstract class GuardianChild with _$GuardianChild { + const GuardianChild._(); + + const factory GuardianChild({ + required String id, + required String firstName, + required String lastName, + @Default('') String className, + }) = _GuardianChild; + + factory GuardianChild.fromJson(Map json) => + _$GuardianChildFromJson(json); + + String get displayName => '$firstName $lastName'.trim(); +} diff --git a/lib/api/marianumconnect/queries/get_capabilities/guardian_child.freezed.dart b/lib/api/marianumconnect/queries/get_capabilities/guardian_child.freezed.dart new file mode 100644 index 0000000..20dd15d --- /dev/null +++ b/lib/api/marianumconnect/queries/get_capabilities/guardian_child.freezed.dart @@ -0,0 +1,294 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// 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 'guardian_child.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$GuardianChild { + + String get id; String get firstName; String get lastName; String get className; +/// Create a copy of GuardianChild +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$GuardianChildCopyWith get copyWith => _$GuardianChildCopyWithImpl(this as GuardianChild, _$identity); + + /// Serializes this GuardianChild to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + final _this = this as GuardianChild; + return identical(this, other) || (other.runtimeType == runtimeType&&other is GuardianChild&&(identical(other.id, _this.id) || other.id == _this.id)&&(identical(other.firstName, _this.firstName) || other.firstName == _this.firstName)&&(identical(other.lastName, _this.lastName) || other.lastName == _this.lastName)&&(identical(other.className, _this.className) || other.className == _this.className)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode { + final _this = this as GuardianChild; + return Object.hash(runtimeType,_this.id,_this.firstName,_this.lastName,_this.className); +} + +@override +String toString() { + final _this = this as GuardianChild; + return 'GuardianChild(id: ${_this.id}, firstName: ${_this.firstName}, lastName: ${_this.lastName}, className: ${_this.className})'; +} + + +} + +/// @nodoc +abstract mixin class $GuardianChildCopyWith<$Res> { + factory $GuardianChildCopyWith(GuardianChild value, $Res Function(GuardianChild) _then) = _$GuardianChildCopyWithImpl; +@useResult +$Res call({ + String id, String firstName, String lastName, String className +}); + + + + +} +/// @nodoc +class _$GuardianChildCopyWithImpl<$Res> + implements $GuardianChildCopyWith<$Res> { + _$GuardianChildCopyWithImpl(this._self, this._then); + + final GuardianChild _self; + final $Res Function(GuardianChild) _then; + +/// Create a copy of GuardianChild +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? firstName = null,Object? lastName = null,Object? className = null,}) { + return _then(GuardianChild( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,firstName: null == firstName ? _self.firstName : firstName // ignore: cast_nullable_to_non_nullable +as String,lastName: null == lastName ? _self.lastName : lastName // ignore: cast_nullable_to_non_nullable +as String,className: null == className ? _self.className : className // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// Adds pattern-matching-related methods to [GuardianChild]. +extension GuardianChildPatterns on GuardianChild { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _GuardianChild value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _GuardianChild() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _GuardianChild value) $default,){ +final _that = this; +switch (_that) { +case _GuardianChild(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _GuardianChild value)? $default,){ +final _that = this; +switch (_that) { +case _GuardianChild() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String id, String firstName, String lastName, String className)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _GuardianChild() when $default != null: +return $default(_that.id,_that.firstName,_that.lastName,_that.className);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String id, String firstName, String lastName, String className) $default,) {final _that = this; +switch (_that) { +case _GuardianChild(): +return $default(_that.id,_that.firstName,_that.lastName,_that.className);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, String firstName, String lastName, String className)? $default,) {final _that = this; +switch (_that) { +case _GuardianChild() when $default != null: +return $default(_that.id,_that.firstName,_that.lastName,_that.className);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _GuardianChild extends GuardianChild { + const _GuardianChild({required this.id, required this.firstName, required this.lastName, this.className = ''}): super._(); + factory _GuardianChild.fromJson(Map json) => _$GuardianChildFromJson(json); + +@override final String id; +@override final String firstName; +@override final String lastName; +@override@JsonKey() final String className; + +/// Create a copy of GuardianChild +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$GuardianChildCopyWith<_GuardianChild> get copyWith => __$GuardianChildCopyWithImpl<_GuardianChild>(this, _$identity); + +@override +Map toJson() { + return _$GuardianChildToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _GuardianChild&&(identical(other.id, id) || other.id == id)&&(identical(other.firstName, firstName) || other.firstName == firstName)&&(identical(other.lastName, lastName) || other.lastName == lastName)&&(identical(other.className, className) || other.className == className)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode { + return Object.hash(runtimeType,id,firstName,lastName,className); +} + +@override +String toString() { + return 'GuardianChild(id: $id, firstName: $firstName, lastName: $lastName, className: $className)'; +} + + +} + +/// @nodoc +abstract mixin class _$GuardianChildCopyWith<$Res> implements $GuardianChildCopyWith<$Res> { + factory _$GuardianChildCopyWith(_GuardianChild value, $Res Function(_GuardianChild) _then) = __$GuardianChildCopyWithImpl; +@override @useResult +$Res call({ + String id, String firstName, String lastName, String className +}); + + + + +} +/// @nodoc +class __$GuardianChildCopyWithImpl<$Res> + implements _$GuardianChildCopyWith<$Res> { + __$GuardianChildCopyWithImpl(this._self, this._then); + + final _GuardianChild _self; + final $Res Function(_GuardianChild) _then; + +/// Create a copy of GuardianChild +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? firstName = null,Object? lastName = null,Object? className = null,}) { + return _then(_GuardianChild( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,firstName: null == firstName ? _self.firstName : firstName // ignore: cast_nullable_to_non_nullable +as String,lastName: null == lastName ? _self.lastName : lastName // ignore: cast_nullable_to_non_nullable +as String,className: null == className ? _self.className : className // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +// dart format on diff --git a/lib/api/marianumconnect/queries/get_capabilities/guardian_child.g.dart b/lib/api/marianumconnect/queries/get_capabilities/guardian_child.g.dart new file mode 100644 index 0000000..bd9554b --- /dev/null +++ b/lib/api/marianumconnect/queries/get_capabilities/guardian_child.g.dart @@ -0,0 +1,23 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'guardian_child.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_GuardianChild _$GuardianChildFromJson(Map json) => + _GuardianChild( + id: json['id'] as String, + firstName: json['firstName'] as String, + lastName: json['lastName'] as String, + className: json['className'] as String? ?? '', + ); + +Map _$GuardianChildToJson(_GuardianChild instance) => + { + 'id': instance.id, + 'firstName': instance.firstName, + 'lastName': instance.lastName, + 'className': instance.className, + }; diff --git a/lib/api/marianumconnect/queries/push_device_register/push_device_register.dart b/lib/api/marianumconnect/queries/push_device_register/push_device_register.dart index f094756..93fad24 100644 --- a/lib/api/marianumconnect/queries/push_device_register/push_device_register.dart +++ b/lib/api/marianumconnect/queries/push_device_register/push_device_register.dart @@ -1,16 +1,18 @@ import '../../marianumconnect_query.dart'; /// Registers (upserts) this device's push subscription with MarianumConnect via -/// `PUT /api/mobile/v1/me/push-device`. The backend verifies the Nextcloud -/// device-identifier signature, stores the routing metadata and starts -/// forwarding Nextcloud pushes to this device's FCM token. Responds 204. +/// `PUT /api/mobile/v1/me/push-device`. For Nextcloud registrations the backend +/// verifies the device-identifier signature and forwards Nextcloud pushes to +/// this device's FCM token; `direct` registrations (accounts without +/// Nextcloud) carry no signature and only receive MarianumConnect pushes. +/// Responds 204. class PushDeviceRegister extends MarianumConnectQuery { PushDeviceRegister({super.dio}); Future run({ required String deviceIdentifier, - required String deviceIdentifierSignature, - required String userPublicKey, + String? deviceIdentifierSignature, + String? userPublicKey, required String pushToken, required String platform, required String registrationType, @@ -20,12 +22,13 @@ class PushDeviceRegister extends MarianumConnectQuery { endpoint('me/push-device'), data: { 'deviceIdentifier': deviceIdentifier, - 'deviceIdentifierSignature': deviceIdentifierSignature, - 'userPublicKey': userPublicKey, + 'deviceIdentifierSignature': ?deviceIdentifierSignature, + 'userPublicKey': ?userPublicKey, 'pushToken': pushToken, 'platform': platform, // 'general' | 'talk' — the backend derives the NC hash comparison // value from it (general = sha512(token), talk = sha512(token+'#talk')). + // 'direct' — no Nextcloud subscription behind it. 'registrationType': registrationType, 'appVersion': ?appVersion, }, 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 ab63377..52a94cc 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:localstore/localstore.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'; @@ -28,12 +28,15 @@ class CustomEventsMigration { const CustomEventsMigration._(); static Future runOnce() async { - if (DemoMode.active) return; + // Guardians never had MHSL events; only password accounts can derive the + // legacy identity. + final session = SessionManager().current; + if (session is! CredentialSession || session.isDemo) return; if (await _isDone()) return; try { final response = await GetCustomTimetableEvent( - GetCustomTimetableEventParams(AccountData().getUserSecret()), + GetCustomTimetableEventParams(session.legacyUserSecret), ).run(); for (final event in response.events) { @@ -44,7 +47,9 @@ class CustomEventsMigration { } 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/marianumconnect/queries/timetable_get_child_week/timetable_get_child_week.dart b/lib/api/marianumconnect/queries/timetable_get_child_week/timetable_get_child_week.dart new file mode 100644 index 0000000..3d47541 --- /dev/null +++ b/lib/api/marianumconnect/queries/timetable_get_child_week/timetable_get_child_week.dart @@ -0,0 +1,18 @@ +import '../../marianumconnect_query.dart'; +import '../timetable_get_week/timetable_get_week_response.dart'; + +/// Fetches the weekly timetable of a guardian's child from +/// `timetable/child/{childId}`. Same response shape as `timetable/me`. +class TimetableGetChildWeek extends MarianumConnectQuery { + TimetableGetChildWeek({super.dio}); + + Future run({ + required String childId, + required DateTime from, + required DateTime until, + }) => getObject( + 'timetable/child/${Uri.encodeComponent(childId)}', + TimetableGetWeekResponse.fromJson, + queryParameters: {'from': isoDate(from), 'until': isoDate(until)}, + ); +} diff --git a/lib/app.dart b/lib/app.dart index c78f748..e2c4e1b 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -15,7 +15,9 @@ 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/infrastructure/loadable_state/loadable_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'; @@ -23,13 +25,16 @@ import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import 'state/app/modules/settings/bloc/settings_cubit.dart'; import 'state/app/modules/timetable/bloc/timetable_bloc.dart'; import 'state/app/modules/timetable/bloc/timetable_state.dart'; +import 'state/app/modules/timetable/policy/timetable_policy.dart'; import 'storage/settings.dart' as model; import 'utils/debouncer.dart'; import 'utils/haptics.dart'; import 'view/pages/overhang.dart'; import 'widget/breaker/breaker.dart'; +import 'widget/info_dialog.dart'; import 'widget_data/widget_navigation.dart'; import 'widget_data/widget_publisher.dart'; +import 'widget_data/widget_sync.dart'; class App extends StatefulWidget { const App({super.key}); @@ -40,7 +45,6 @@ class App extends StatefulWidget { class _AppState extends State with WidgetsBindingObserver { late Timer _updateTimings; - StreamSubscription? _timetableWidgetSync; StreamSubscription? _onMessageSub; StreamSubscription? _onMessageOpenedAppSub; StreamSubscription? _fcmTokenRefreshSub; @@ -142,12 +146,43 @@ class _AppState extends State with WidgetsBindingObserver { AppRoutes.goToTab(context, Modules.timetable); } + /// Mirrors the primary plan into the home-screen widget without waiting + /// for the periodic background refresh. + void _publishWidget(TimetableBloc bloc) { + final data = bloc.state.data; + if (!mounted || data is! TimetableState) return; + if (WidgetSync.encodeSubject(bloc.subject) == null) return; + unawaited( + WidgetPublisher.publishFromBlocState( + data, + subject: bloc.subject, + settings: context.read().val(), + showClassInsteadOfTeacher: TimetablePolicy.resolve( + subject: bloc.subject, + capabilities: context.read().state, + ).showClassInsteadOfTeacher, + ), + ); + } + void _handlePendingShare() { if (!mounted) return; final share = ShareIntentListener.pending.value; if (share == null) return; // A second share would otherwise leave the previous share-flow page // on top with stale (already-cleared) file paths. + // Sharing targets Talk chats and Files folders only. + final session = SessionManager().current; + if (!AppModule.isAvailableFor(Modules.talk, session) && + !AppModule.isAvailableFor(Modules.files, session)) { + ShareIntentListener.instance.clear(); + InfoDialog.show( + context, + 'Mit diesem Konto können keine Inhalte in die App geteilt werden.', + title: 'Teilen nicht möglich', + ); + return; + } final navigator = Navigator.of(context); if (navigator.canPop()) { navigator.popUntil((route) => route.isFirst || route is PopupRoute); @@ -168,37 +203,9 @@ class _AppState extends State with WidgetsBindingObserver { if (!mounted) return; context.read().refresh(); context.read().refresh(); - // Re-mounts on every login, so this also covers post-logout state reset. - final timetable = context.read(); - timetable.refresh(); - // Mirror BLoC updates into the home-screen widget without waiting - // for the periodic background refresh. - final settingsCubit = context.read(); - final capabilitiesCubit = context.read(); - _timetableWidgetSync?.cancel(); - _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, - ), - ); - } - }); - // Initial publish in case hydrated storage already has data. - final initialData = timetable.state.data; - if (initialData is TimetableState) { - unawaited( - WidgetPublisher.publishFromBlocState( - initialData, - settings: settingsCubit.val(), - isTeacher: capabilitiesCubit.isTeacher, - ), - ); - } + // Initial publish in case hydrated storage already has data. No refresh + // needed: PrimaryTimetableScope hands out a freshly loading bloc. + _publishWidget(context.read()); unawaited(_handlePendingWidgetNavigation()); ShareIntentListener.instance.attach(); ShareIntentListener.pending.addListener(_handlePendingShare); @@ -254,7 +261,6 @@ class _AppState extends State with WidgetsBindingObserver { @override void dispose() { _updateTimings.cancel(); - _timetableWidgetSync?.cancel(); _onMessageSub?.cancel(); _onMessageOpenedAppSub?.cancel(); _fcmTokenRefreshSub?.cancel(); @@ -268,7 +274,24 @@ class _AppState extends State with WidgetsBindingObserver { } @override - Widget build( + Widget build(BuildContext context) => + BlocListener>( + // Also follows a swapped instance (child switch), unlike a manual + // stream subscription. + listenWhen: (_, state) => !state.isLoading, + // A week change emits several times (week, prefetched neighbours); + // one widget reload for the burst is enough. + listener: (_, _) => Debouncer.debounce( + 'widgetPublish', + const Duration(milliseconds: 500), + () { + if (mounted) _publishWidget(context.read()); + }, + ), + child: _buildShell(context), + ); + + Widget _buildShell( BuildContext context, ) => BlocBuilder( builder: (context, _) { diff --git a/lib/auth_link/device_binding.dart b/lib/auth_link/device_binding.dart new file mode 100644 index 0000000..d1e765d --- /dev/null +++ b/lib/auth_link/device_binding.dart @@ -0,0 +1,22 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:crypto/crypto.dart'; + +/// Binds a guardian login request to the device that started it (PKCE-style): +/// the request carries only [challengeFor] of a secret that never leaves the +/// device, verification sends the secret itself. A mail link opened on another +/// device therefore cannot complete the login. +abstract final class DeviceBinding { + static String generateSecret([Random? random]) { + final rng = random ?? Random.secure(); + final bytes = List.generate(32, (_) => rng.nextInt(256)); + return _base64UrlNoPad(bytes); + } + + static String challengeFor(String secret) => + _base64UrlNoPad(sha256.convert(utf8.encode(secret)).bytes); + + static String _base64UrlNoPad(List bytes) => + base64Url.encode(bytes).replaceAll('=', ''); +} diff --git a/lib/auth_link/guardian_link_listener.dart b/lib/auth_link/guardian_link_listener.dart new file mode 100644 index 0000000..91297df --- /dev/null +++ b/lib/auth_link/guardian_link_listener.dart @@ -0,0 +1,45 @@ +import 'package:app_links/app_links.dart'; +import 'package:flutter/foundation.dart'; + +import 'guardian_login_link.dart'; + +/// Bridges incoming App Links / Universal Links into [pending]; the login +/// screen consumes it. Mirrors ShareIntentListener: [initialize] reads the +/// cold-start link before `runApp` and then follows links while running. +/// +/// Links are kept raw and parsed on consumption: the endpoint setting the +/// link is checked against is only applied once the app is built. +class GuardianLinkListener { + GuardianLinkListener._(); + static final GuardianLinkListener instance = GuardianLinkListener._(); + + static final ValueNotifier pending = ValueNotifier(null); + + final AppLinks _appLinks = AppLinks(); + bool _listening = false; + + Future initialize() async { + try { + final initial = await _appLinks.getInitialLink(); + if (initial != null) _publish(initial); + } catch (e) { + debugPrint('GuardianLinkListener.initialize failed: $e'); + } + if (_listening) return; + _listening = true; + // Kept for the whole process lifetime; links can arrive at any time. + _appLinks.uriLinkStream.listen( + _publish, + onError: (Object e) => debugPrint('GuardianLinkListener error: $e'), + ); + } + + // Cheap pre-filter; the host check against the active endpoint happens in + // GuardianLoginLink.parse. + void _publish(Uri uri) { + if (!uri.path.endsWith(GuardianLoginLink.path)) return; + pending.value = uri; + } + + static void clear() => pending.value = null; +} diff --git a/lib/auth_link/guardian_login_link.dart b/lib/auth_link/guardian_login_link.dart new file mode 100644 index 0000000..f56d96c --- /dev/null +++ b/lib/auth_link/guardian_login_link.dart @@ -0,0 +1,26 @@ +/// A sign-in link from the guardian login mail +/// (`https:///app/guardian-login?rid=…<=…`). +class GuardianLoginLink { + static const path = '/app/guardian-login'; + + final String requestId; + final String linkToken; + + const GuardianLoginLink({required this.requestId, required this.linkToken}); + + /// Parses [uri] if it is a guardian login link for the server at [apiBase]. + /// Links of another server (e.g. live link while the app points at beta) + /// are rejected: the request only exists on the server that sent the mail. + static GuardianLoginLink? parse(Uri uri, {required Uri apiBase}) { + if (uri.scheme != 'https' || uri.host != apiBase.host) return null; + final basePath = apiBase.path.endsWith('/') + ? apiBase.path.substring(0, apiBase.path.length - 1) + : apiBase.path; + if (uri.path != '$basePath$path') return null; + final requestId = uri.queryParameters['rid']; + final linkToken = uri.queryParameters['lt']; + if (requestId == null || requestId.isEmpty) return null; + if (linkToken == null || linkToken.isEmpty) return null; + return GuardianLoginLink(requestId: requestId, linkToken: linkToken); + } +} diff --git a/lib/auth_link/pending_guardian_request.dart b/lib/auth_link/pending_guardian_request.dart new file mode 100644 index 0000000..3db0025 --- /dev/null +++ b/lib/auth_link/pending_guardian_request.dart @@ -0,0 +1,83 @@ +import 'dart:convert'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +/// A guardian login request waiting for its code or link. Persisted because +/// Android often kills the app while the user reads the mail. +class PendingGuardianRequest { + static const int defaultCodeLength = 6; + + final String requestId; + final String email; + final String deviceSecret; + final DateTime expiresAt; + final DateTime resendAvailableAt; + final int codeLength; + + const PendingGuardianRequest({ + required this.requestId, + required this.email, + required this.deviceSecret, + required this.expiresAt, + required this.resendAvailableAt, + this.codeLength = defaultCodeLength, + }); + + bool isExpired(DateTime now) => !now.isBefore(expiresAt); + + Map toJson() => { + 'requestId': requestId, + 'email': email, + 'deviceSecret': deviceSecret, + 'expiresAt': expiresAt.toIso8601String(), + 'resendAvailableAt': resendAvailableAt.toIso8601String(), + 'codeLength': codeLength, + }; + + static PendingGuardianRequest? fromJson(Map json) { + try { + return PendingGuardianRequest( + requestId: json['requestId'] as String, + email: json['email'] as String, + deviceSecret: json['deviceSecret'] as String, + expiresAt: DateTime.parse(json['expiresAt'] as String), + resendAvailableAt: DateTime.parse(json['resendAvailableAt'] as String), + codeLength: json['codeLength'] as int? ?? defaultCodeLength, + ); + } on Object { + return null; + } + } +} + +class PendingGuardianRequestStore { + static const _key = 'guardian_login_pending_request'; + static const FlutterSecureStorage _storage = FlutterSecureStorage( + iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock), + ); + + const PendingGuardianRequestStore(); + + Future read() async { + try { + final raw = await _storage.read(key: _key); + if (raw == null) return null; + return PendingGuardianRequest.fromJson( + jsonDecode(raw) as Map, + ); + } on Object { + return null; + } + } + + Future write(PendingGuardianRequest request) => + _storage.write(key: _key, value: jsonEncode(request.toJson())); + + Future clear() async { + try { + await _storage.delete(key: _key); + } on Object { + // Nothing stored or keystore unavailable. + } + } +} diff --git a/lib/background/widget_background_task.dart b/lib/background/widget_background_task.dart index 82f71f3..2aec491 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'; @@ -14,11 +15,11 @@ import '../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subj import '../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart'; 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 '../model/account_data.dart'; +import '../session/session.dart'; +import '../session/session_manager.dart'; +import '../state/app/modules/timetable/data_provider/timetable_data_provider.dart'; +import '../state/app/modules/timetable/subject/timetable_subject.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 @@ -101,11 +102,23 @@ class WidgetBackgroundTask { return; } final fetchedAt = await WidgetSync.getFetchedAt(); - if (shouldSkipRefresh(fetchedAt: fetchedAt, now: DateTime.now(), force: force)) { + if (shouldSkipRefresh( + fetchedAt: fetchedAt, + now: DateTime.now(), + force: force, + )) { log('[widget-refresh] snapshot is fresh, skipping refresh'); return; } - await _refresh(); + final subject = widgetRefreshSubject( + session: session, + stored: await WidgetSync.getSubject(), + ); + if (subject == null) { + log('[widget-refresh] no plan selected yet, skipping refresh'); + return; + } + await _refresh(subject); } static Future cancelAll() async { @@ -126,6 +139,16 @@ bool shouldSkipRefresh({ return !age.isNegative && age < WidgetBackgroundTask.refreshDebounce; } +/// The plan the background refresh loads. Guardians only get one once the app +/// has published a child; before that there is nothing sensible to fetch. +TimetableSubject? widgetRefreshSubject({ + required Session session, + required TimetableSubject? stored, +}) => switch (session) { + CredentialSession() => const OwnTimetable(), + GuardianSession() => stored is ChildTimetable ? stored : null, +}; + @pragma('vm:entry-point') void _callbackDispatcher() { Workmanager().executeTask((task, inputData) async { @@ -142,7 +165,7 @@ void _callbackDispatcher() { }); } -Future _refresh() async { +Future _refresh(TimetableSubject subject) 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 @@ -165,9 +188,11 @@ Future _refresh() async { // latency is the slowest request, not the sum (matters for the push path's // hard time budget). Reference-data failures fall through to null in the // mapper rather than aborting the whole refresh. - final timetableFuture = TimetableGetWeek().run( + final until = weekEndExclusive.subtract(const Duration(days: 1)); + final timetableFuture = TimetableDataProvider.fetchWeek( + subject, from: weekStart, - until: weekEndExclusive.subtract(const Duration(days: 1)), + until: until, ); final subjectsFuture = _runOrNull( () => TimetableGetSubjects().run(), @@ -181,11 +206,11 @@ Future _refresh() async { final timegridFuture = _runOrNull( () => TimetableGetTimegrid().run(), ); - final customEventsFuture = _runOrNull( - () => GetCustomTimetableEvent( - GetCustomTimetableEventParams(AccountData().getUserSecret()), - ).run(), - ); + final customEventsFuture = subject.supportsCustomEvents + ? _runOrNull( + () => TimetableCustomEventsGet().run(), + ) + : Future.value(); final timetable = await timetableFuture; final subjects = await subjectsFuture; final rooms = await roomsFuture; @@ -195,9 +220,9 @@ Future _refresh() async { final lessons = timetable.entries; - final [connectDouble, isTeacher] = await Future.wait([ + final [connectDouble, showClassInsteadOfTeacher] = await Future.wait([ WidgetSync.getConnectDoubleLessons(), - WidgetSync.getIsTeacher(), + WidgetSync.getShowClassInsteadOfTeacher(), ]); final dayData = WidgetDataMapper.buildDayData( now: now, @@ -208,7 +233,7 @@ Future _refresh() async { timegrid: timegrid, customEvents: customEvents, connectDoubleLessons: connectDouble, - showClassInsteadOfTeacher: isTeacher, + showClassInsteadOfTeacher: showClassInsteadOfTeacher, ); final weekData = WidgetDataMapper.buildWeekData( now: now, @@ -219,9 +244,15 @@ Future _refresh() async { timegrid: timegrid, customEvents: customEvents, connectDoubleLessons: connectDouble, - showClassInsteadOfTeacher: isTeacher, + showClassInsteadOfTeacher: showClassInsteadOfTeacher, ); + // The user may have switched the child while the requests ran; writing now + // would show the previous child's plan. + if (await WidgetSync.getSubject() != subject) { + log('[widget-bg] subject changed during refresh, discarding'); + return; + } await WidgetSync.writeDayData(dayData); await WidgetSync.writeWeekData(weekData); await WidgetSync.setLoggedIn(true); diff --git a/lib/main.dart b/lib/main.dart index e06a754..e102655 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -24,15 +24,16 @@ import 'api/marianumconnect/queries/get_breakers/get_breakers_response.dart'; import 'api/marianumconnect/queries/report_client_error/client_error_reporter.dart'; import 'api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart'; import 'app.dart'; +import 'auth_link/guardian_link_listener.dart'; import 'background/widget_background_task.dart'; import 'firebase_options.dart'; -import 'model/account_data.dart'; import 'notification/notification_service.dart'; import 'push/push_message_handler.dart'; import 'push/push_registration.dart'; import 'push/push_registration_store.dart'; import 'push/push_renderer.dart'; import 'routing/app_routes.dart'; +import 'session/session_manager.dart'; import 'share_intent/share_intent_listener.dart'; import 'state/app/modules/account/bloc/account_bloc.dart'; import 'state/app/modules/account/bloc/account_state.dart'; @@ -40,14 +41,16 @@ import 'state/app/modules/breaker/bloc/breaker_bloc.dart'; import 'state/app/modules/capabilities/bloc/capabilities_cubit.dart'; import 'state/app/modules/chat/bloc/chat_bloc.dart'; import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart'; +import 'state/app/modules/children/child_selection_cubit.dart'; import 'state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart'; import 'state/app/modules/settings/bloc/settings_cubit.dart'; -import 'state/app/modules/timetable/bloc/timetable_bloc.dart'; +import 'state/app/modules/timetable/primary/primary_timetable_scope.dart'; import 'storage/hydrated_storage_bootstrap.dart'; import 'storage/settings.dart'; import 'theming/dark_app_theme.dart'; import 'theming/light_app_theme.dart'; import 'utils/app_paths.dart'; +import 'utils/debouncer.dart'; import 'utils/downloads/download_manager.dart'; import 'view/login/account_loading_screen.dart'; import 'view/login/login.dart'; @@ -149,17 +152,18 @@ 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, ), _startupStep('share intent', ShareIntentListener.instance.initialize), + _startupStep('guardian link', GuardianLinkListener.instance.initialize), ]; log('starting app initialisation...'); @@ -215,7 +219,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'), @@ -228,11 +232,17 @@ Future main() async { // placeholder flash. AvatarDiskCache.instance.warmUp(); + // Created eagerly so the endpoint is configured before anything below can + // issue a request (the primary timetable bloc loads on creation). + final settingsCubit = SettingsCubit(); + _syncMarianumConnectEndpoint(settingsCubit.state); + settingsCubit.stream.listen(_syncMarianumConnectEndpoint); + log('running app...'); runApp( MultiBlocProvider( providers: [ - BlocProvider(create: (_) => SettingsCubit()), + BlocProvider.value(value: settingsCubit), BlocProvider( create: (_) => AccountBloc(initialStatus: _initialAccountStatus()), ), @@ -245,17 +255,31 @@ Future main() async { BlocProvider( create: (ctx) => ChatBloc(chatListBloc: ctx.read()), ), - BlocProvider(create: (_) => TimetableBloc()), + BlocProvider(create: (_) => ChildSelectionCubit()), ], - child: const Main(), + child: const PrimaryTimetableScope(child: Main()), ), ); } +String? _syncedMcBaseUrl; + +/// Keeps the MC dio singleton aligned with the selected endpoint (live / +/// beta / custom), mirrored into WidgetSync so the background isolate +/// refreshes against the same endpoint. Settings emit on every toggle; only +/// an actual URL change is applied. +void _syncMarianumConnectEndpoint(Settings settings) { + final url = settings.devToolsSettings.resolveMarianumConnectBaseUrl(); + if (url == _syncedMcBaseUrl) return; + _syncedMcBaseUrl = url; + MarianumConnectEndpoint.update(url); + unawaited(WidgetSync.setMarianumConnectBaseUrl(url)); +} + AccountStatus _initialAccountStatus() { - final 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 { @@ -278,35 +302,44 @@ class _MainState extends State
{ super.initState(); Jiffy.setLocale('de'); - AccountData().waitForPopulation().then((value) { + SessionManager().unauthorizedSignal.addListener(_onUnauthorized); + 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 - // capabilities here, then self-heal the push registration. - final settingsCubit = context.read(); - unawaited( - context.read().load().then((_) { - if (!mounted) return; - _syncPush(settingsCubit, context.read()); - }), - ); - unawaited(context.read().load()); + // change, so the loggedIn listener below never fires. + _onSessionActive(); } }); } - /// Warms the core caches (timetable, chat list, files root) in the - /// background so the first screen render hits populated data. + /// Pulls the capability flags of the active account, then registers push + /// right away instead of deferring it to the next app start. + void _onSessionActive() { + final settingsCubit = context.read(); + final capabilitiesCubit = context.read(); + unawaited( + capabilitiesCubit.load().then((_) { + if (!mounted) return; + _syncPush(settingsCubit, capabilitiesCubit); + }), + ); + unawaited(context.read().load()); + } + + /// Warms the chat list and files root in the background so the first screen + /// render hits populated data. The timetable needs no warm-up: + /// PrimaryTimetableScope creates a freshly loading bloc per account. void _prefetchBaseData(BuildContext context) { - 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 @@ -329,6 +362,23 @@ class _MainState extends State
{ ); } + @override + void dispose() { + SessionManager().unauthorizedSignal.removeListener(_onUnauthorized); + super.dispose(); + } + + void _onUnauthorized() { + if (!mounted) return; + final accountBloc = context.read(); + if (accountBloc.state.status != AccountStatus.loggedIn) return; + Debouncer.throttle( + 'sessionUnauthorized', + const Duration(seconds: 30), + () => _scheduleSessionValidation(accountBloc), + ); + } + /// Background credential check: a 401 means the password was rotated /// server-side, so the validator wipes the local session and flips the /// account bloc to `loggedOut` (sending the user to the login screen). @@ -349,14 +399,6 @@ class _MainState extends State
{ child: BlocBuilder( builder: (context, settings) { final devToolsSettings = settings.devToolsSettings; - // 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)); // Mirror the notification toggle into group-scoped storage so the FCM // background isolate and the iOS NSE can suppress rendering when off. unawaited( @@ -409,22 +451,8 @@ class _MainState extends State
{ listenWhen: (previous, current) => previous.status != current.status, listener: (context, accountState) { - // 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); - }), - ); - unawaited( - context.read().load(), - ); + _onSessionActive(); _showPostLoginSplash = true; _appMounted = false; WidgetsBinding.instance.addPostFrameCallback((_) { @@ -449,11 +477,12 @@ class _MainState extends State
{ // — 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(); final breakerBloc = context.read(); final capabilitiesCubit = context.read(); + final childSelectionCubit = context + .read(); + final chatListBloc = context.read(); + final chatBloc = context.read(); final nextcloudCapabilitiesCubit = context .read(); // Defer the actual wipe until after this frame so the @@ -464,7 +493,7 @@ class _MainState extends State
{ unawaited( _wipeUserState( settingsCubit: settingsCubit, - timetableBloc: timetableBloc, + childSelectionCubit: childSelectionCubit, chatListBloc: chatListBloc, chatBloc: chatBloc, breakerBloc: breakerBloc, @@ -510,7 +539,7 @@ class _MainState extends State
{ Future _wipeUserState({ required SettingsCubit settingsCubit, - required TimetableBloc timetableBloc, + required ChildSelectionCubit childSelectionCubit, required ChatListBloc chatListBloc, required ChatBloc chatBloc, required BreakerBloc breakerBloc, @@ -523,8 +552,11 @@ Future _wipeUserState({ // wraps MaterialApp, so emit'ing a fresh state would tear down the // freshly-mounted Login tree and leave the user with a blank screen // (the MaterialApp.builder backdrop) until the next interaction. + // The timetable bloc is not reset here: PrimaryTimetableScope replaces it + // on the status change, and HydratedBloc.storage.clear() below drops the + // cached plans of every subject. await Future.wait([ - timetableBloc.reset(), + childSelectionCubit.reset(), chatListBloc.reset(), chatBloc.reset(), breakerBloc.reset(), diff --git a/lib/model/account_data.dart b/lib/model/account_data.dart deleted file mode 100644 index 4f96459..0000000 --- a/lib/model/account_data.dart +++ /dev/null @@ -1,333 +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()); - } - - 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 { - _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(); - _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'; - try { - _appPassword = await pushSecureStorage.read(key: _appPasswordField); - _appPasswordTalk = await pushSecureStorage.read( - key: _appPasswordTalkField, - ); - } 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); - } - } - - 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/notification/notification_tasks.dart b/lib/notification/notification_tasks.dart index e3eb449..fd5b92b 100644 --- a/lib/notification/notification_tasks.dart +++ b/lib/notification/notification_tasks.dart @@ -8,6 +8,8 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../push/chat_thread_store.dart'; import '../routing/app_routes.dart'; +import '../session/session_manager.dart'; +import '../state/app/modules/app_modules.dart'; import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import 'notification_service.dart'; @@ -76,6 +78,9 @@ class NotificationTasks { /// the matching chat to be opened automatically once the chat list view /// resolves the token (handled inside [ChatList]). static void navigateToTalk(BuildContext context, {String? chatToken}) { + if (!AppModule.isAvailableFor(Modules.talk, SessionManager().current)) { + return; + } if (chatToken != null && chatToken.isNotEmpty) { AppRoutes.openChatByToken(context, chatToken); } else { diff --git a/lib/push/direct_push_registration.dart b/lib/push/direct_push_registration.dart new file mode 100644 index 0000000..503ae47 --- /dev/null +++ b/lib/push/direct_push_registration.dart @@ -0,0 +1,71 @@ +import 'dart:developer'; + +import 'package:firebase_messaging/firebase_messaging.dart'; + +import '../api/marianumconnect/queries/push_device_register/push_device_register.dart'; +import '../api/marianumconnect/queries/push_device_unregister/push_device_unregister.dart'; +import '../utils/random_id.dart'; +import 'push_device_info.dart'; +import 'push_secure_storage.dart'; + +/// Push registration for accounts without Nextcloud (guardians): the device +/// registers straight with MarianumConnect and only receives its direct +/// pushes (newsletter, widget refresh, later guardian messages). Nextcloud +/// normally supplies the device identifier; here a random one is kept per +/// install. +class DirectPushRegistration { + static const String registrationType = 'direct'; + static const _deviceIdentifierKey = 'push_direct_device_identifier'; + + final FlutterSecureStorageLike _storage; + + const DirectPushRegistration({ + FlutterSecureStorageLike storage = const PushSecureStorage(), + }) : _storage = storage; + + Future register() async { + try { + final (fcmToken, appVersion, identifier) = await ( + FirebaseMessaging.instance.getToken(), + pushAppVersion(), + deviceIdentifier(), + ).wait; + if (fcmToken == null || fcmToken.isEmpty) { + log('Push (direct): no FCM token, skipping registration'); + return false; + } + await PushDeviceRegister().run( + deviceIdentifier: identifier, + pushToken: fcmToken, + platform: pushPlatform, + registrationType: registrationType, + appVersion: appVersion, + ); + return true; + } on Object catch (e) { + log('Push (direct): registration failed: $e'); + return false; + } + } + + Future unregister() async { + final identifier = await _storage.read(key: _deviceIdentifierKey); + if (identifier == null) return; + try { + await PushDeviceUnregister().run(deviceIdentifier: identifier); + } on Object catch (e) { + log('Push (direct): unregister failed: $e'); + } + await _storage.delete(key: _deviceIdentifierKey); + } + + /// Stable per install until [unregister], so re-registrations upsert the + /// same server row instead of piling up devices. + Future deviceIdentifier() async { + final stored = await _storage.read(key: _deviceIdentifierKey); + if (stored != null && stored.isNotEmpty) return stored; + final fresh = randomHexId(); + await _storage.write(key: _deviceIdentifierKey, value: fresh); + return fresh; + } +} diff --git a/lib/push/push_actions.dart b/lib/push/push_actions.dart index 3548143..a8c455a 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,7 +56,7 @@ 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(); @@ -125,7 +125,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'); } @@ -188,7 +189,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'); @@ -200,10 +204,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_registration.dart b/lib/push/push_registration.dart index 1f0d1b7..477352c 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,12 @@ 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 'direct_push_registration.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 +50,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 +63,29 @@ class PushRegistration { /// slash) — persisted alongside the registration to detect endpoint changes. String get currentNcBaseUrl => 'https://${EndpointData().nextcloud().full()}'; + NextcloudCredentials? get _nextcloudOrNull => + SessionManager().current?.nextcloud; + + /// Channel for sessions without Nextcloud; see [DirectPushRegistration]. + static const _direct = DirectPushRegistration(); + /// 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 +94,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,6 +117,7 @@ class PushRegistration { /// fire-and-forget (and simply ignore the result). Future register() async { if (DemoMode.active) return false; + if (_nextcloudOrNull == null) return _direct.register(); final String? fcmToken; try { fcmToken = await FirebaseMessaging.instance.getToken(); @@ -134,16 +145,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( @@ -180,7 +188,7 @@ class PushRegistration { devicePublicKeyPem: pems.publicKeyPem, proxyServer: proxyServer, authorizationHeader: isTalk - ? AccountData().getTalkBasicAuthHeader() + ? SessionManager().requireNextcloud().talkBasicAuthHeader : null, userAgent: isTalk ? _talkUserAgent : null, ); @@ -199,7 +207,7 @@ class PushRegistration { deviceIdentifierSignature: registration.signature, userPublicKey: registration.publicKey, pushToken: fcmToken, - platform: _platform, + platform: pushPlatform, registrationType: type.wireName, appVersion: appVersion, ); @@ -248,7 +256,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) { @@ -266,7 +274,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) { @@ -405,7 +413,9 @@ class PushRegistration { static Future syncSubscription({required bool capable}) async { if (!capable) return false; if (!await isOsPermissionGranted()) { - log('Push: OS notification permission not granted, skipping registration'); + log( + 'Push: OS notification permission not granted, skipping registration', + ); return false; } final registration = PushRegistration(); @@ -429,6 +439,7 @@ class PushRegistration { /// pushing before credentials are gone. Future logoutCleanup() async { if (DemoMode.active) return; + if (_nextcloudOrNull == null) return _direct.unregister(); await unregister(); try { await DeleteAppPassword().run(); @@ -436,15 +447,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 a0664cb..3336080 100644 --- a/lib/push/push_status.dart +++ b/lib/push/push_status.dart @@ -1,7 +1,7 @@ import 'package:firebase_messaging/firebase_messaging.dart'; 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'; @@ -125,14 +125,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 63232c6..ad41593 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'; @@ -374,7 +374,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, @@ -404,7 +407,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); @@ -417,7 +421,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..f06505c --- /dev/null +++ b/lib/session/session.dart @@ -0,0 +1,74 @@ +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 (guardians). + 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(); +} + +/// Parent/guardian account: passwordless e-mail login, MarianumConnect only. +final class GuardianSession extends Session { + final String email; + + const GuardianSession({required this.email, super.isDemo}); + + @override + NextcloudCredentials? get nextcloud => null; +} + +/// 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..23dcb73 --- /dev/null +++ b/lib/session/session_codec.dart @@ -0,0 +1,70 @@ +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'; + + // Added with guardian accounts. Absent on installs from before — see + // [decodeSession]. + static const kind = 'session_kind'; + static const guardianEmail = 'guardian_email'; + + static const kindCredential = 'credential'; + static const kindGuardian = 'guardian'; +} + +/// Rebuilds the session from raw keychain values. Installs from before +/// guardian accounts 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 SessionKeys.kindGuardian: + final email = raw[SessionKeys.guardianEmail]; + if (email == null || email.isEmpty) return null; + return GuardianSession(email: email, isDemo: isDemo); + 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, + SessionKeys.guardianEmail: null, + }, + GuardianSession() => { + SessionKeys.kind: SessionKeys.kindGuardian, + SessionKeys.guardianEmail: session.email, + SessionKeys.demo: session.isDemo ? 'true' : null, + SessionKeys.username: null, + SessionKeys.password: null, + SessionKeys.loginFlow: null, + }, +}; diff --git a/lib/session/session_lifecycle.dart b/lib/session/session_lifecycle.dart new file mode 100644 index 0000000..c591647 --- /dev/null +++ b/lib/session/session_lifecycle.dart @@ -0,0 +1,25 @@ +import 'dart:developer'; + +import '../api/marianumconnect/queries/auth_logout/auth_logout.dart'; +import '../auth_link/guardian_link_listener.dart'; +import '../push/push_registration.dart'; +import 'session_manager.dart'; + +abstract final class SessionLifecycle { + /// 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() async { + try { + await PushRegistration().logoutCleanup(); + } on Object catch (e) { + log('Sign-out: push cleanup failed: $e'); + } + await AuthLogout().run(); + await SessionManager().signOut(); + // A login link that arrived while signed in must not be replayed on the + // login screen that follows. + GuardianLinkListener.clear(); + } +} diff --git a/lib/session/session_manager.dart b/lib/session/session_manager.dart new file mode 100644 index 0000000..a8a2bdc --- /dev/null +++ b/lib/session/session_manager.dart @@ -0,0 +1,248 @@ +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 '../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.guardianEmail, + SessionKeys.demo, + SessionKeys.loginFlow, + ]; + + static final SessionManager _instance = SessionManager._(); + factory SessionManager() => _instance; + + SessionManager._() { + unawaited(_loadWithRetry()); + } + + Completer _loaded = Completer(); + Session? _current; + + Session? get current => _current; + + 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(); + } + + /// Bumped when the server rejects a token that cannot be renewed silently + /// (passwordless accounts). The app confirms via [SessionValidator] before + /// signing out, so a transient 401 does not cost the session. + final ValueNotifier unauthorizedSignal = ValueNotifier(0); + + void reportUnauthorized() => unauthorizedSignal.value++; + + NextcloudCredentials requireNextcloud() => + _current?.nextcloud ?? (throw const NextcloudUnavailableException()); + + /// Replaces any stored session completely; no prior [signOut] needed. + Future signIn(Session session) async { + 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 { + _loaded = Completer(); + _current = null; + await Future.wait([ + for (final field in _sessionFields) _secureStorage.delete(key: field), + _writeGroupSecret(SessionKeys.appPassword, null), + _writeGroupSecret(SessionKeys.appPasswordTalk, null), + ]); + } + + /// 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(); + // On the startup critical path (and every background wake): read in + // parallel instead of one keychain round-trip after the other. + 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. + } + _current = decodeSession(raw); + if (!_loaded.isCompleted) _loaded.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(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 8fe3ffd..064f8c8 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 @@ -2,7 +2,9 @@ import 'dart:developer'; import 'package:hydrated_bloc/hydrated_bloc.dart'; +import '../../../../../access/access_requirement.dart'; import '../../../../../api/errors/error_mapper.dart'; +import '../../../../../session/session_manager.dart'; import '../../loadable_state/loadable_state.dart'; import '../../loadable_state/loading_error.dart'; import '../../repository/repository.dart'; @@ -114,6 +116,13 @@ abstract class LoadableHydratedBloc< add(Reset()); } + /// Backend identities the data needs. Without them loading is a no-op, so + /// reading the bloc in a session that lacks them (e.g. guardians and the + /// Nextcloud blocs) is harmless. + Set get requirements => const {}; + + bool get requirementsMet => requirements.areMetBy(SessionManager().current); + TState? get innerState => state.data; TRepository get repo => _repository; @@ -137,6 +146,7 @@ abstract class LoadableHydratedBloc< ); void fetch() { + if (!requirementsMet) return; log('Fetching data for ${TState.toString()}'); gatherData() .catchError((Object e) { diff --git a/lib/state/app/modules/app_modules.dart b/lib/state/app/modules/app_modules.dart index 59c8c67..b4422e6 100644 --- a/lib/state/app/modules/app_modules.dart +++ b/lib/state/app/modules/app_modules.dart @@ -3,8 +3,11 @@ 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 '../../../access/access_requirement.dart'; import '../../../api/marianumconnect/queries/get_breakers/get_breakers_response.dart'; import '../../../routing/app_routes.dart'; +import '../../../session/session.dart'; +import '../../../session/session_manager.dart'; import '../../../storage/modules_settings.dart'; import '../../../view/pages/absence_report/absence_report_view.dart'; import '../../../view/pages/files/files.dart'; @@ -38,6 +41,16 @@ class AppModule { required this.create, }); + /// Backend identities each module needs. Modules without an entry work for + /// every session. + static const Map> requirements = { + Modules.talk: {AccessRequirement.nextcloud}, + Modules.files: {AccessRequirement.nextcloud}, + }; + + static bool isAvailableFor(Modules module, Session? session) => + (requirements[module] ?? const {}).areMetBy(session); + static Map modules( BuildContext context, { bool showFiltered = false, @@ -146,6 +159,9 @@ class AppModule { ), }; + final session = SessionManager().current; + available.removeWhere((key, _) => !isAvailableFor(key, session)); + if (!showFiltered) { available.removeWhere( (key, value) => @@ -177,9 +193,7 @@ class AppModule { for (final missing in Modules.values) { if (!seen.add(missing)) continue; var insertAt = 0; - for (final predecessor in Modules.values.takeWhile( - (m) => m != missing, - )) { + for (final predecessor in Modules.values.takeWhile((m) => m != missing)) { final pos = order.indexOf(predecessor); if (pos >= insertAt) insertAt = pos + 1; } diff --git a/lib/state/app/modules/capabilities/bloc/capabilities_cubit.dart b/lib/state/app/modules/capabilities/bloc/capabilities_cubit.dart index 8290af6..d5aef75 100644 --- a/lib/state/app/modules/capabilities/bloc/capabilities_cubit.dart +++ b/lib/state/app/modules/capabilities/bloc/capabilities_cubit.dart @@ -5,6 +5,8 @@ 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 '../../../../../session/session.dart'; +import '../../../../../session/session_manager.dart'; import 'capabilities_state.dart'; /// Holds the current user's mobile capability flags. Hydrated so the last @@ -21,17 +23,17 @@ class CapabilitiesCubit extends HydratedCubit { int? get timetableFutureDays => state.timetableFutureDays; - /// Teacher accounts get the class shown on timetable tiles instead of their - /// own name (see TimetableAppointmentFactory.showClassInsteadOfTeacher). - bool get isTeacher => state.userType == 'TEACHER'; - /// Refreshes capabilities from the server. On any failure (endpoint not yet /// live, network error, 4xx) the previously hydrated flags are kept but the /// state is marked `loaded` — a failed fetch never silently grants a /// capability, and an offline launch keeps whatever was cached. Future load() async { if (DemoMode.active) { - emit(DemoCapabilities.state()); + emit( + SessionManager().current is GuardianSession + ? DemoCapabilities.guardianState() + : DemoCapabilities.state(), + ); return; } try { @@ -43,6 +45,7 @@ class CapabilitiesCubit extends HydratedCubit { timetablePastDays: response.timetablePastDays, timetableFutureDays: response.timetableFutureDays, userType: response.userType, + children: response.children, loaded: true, ), ); diff --git a/lib/state/app/modules/capabilities/bloc/capabilities_state.dart b/lib/state/app/modules/capabilities/bloc/capabilities_state.dart index 3722d5a..c0c8368 100644 --- a/lib/state/app/modules/capabilities/bloc/capabilities_state.dart +++ b/lib/state/app/modules/capabilities/bloc/capabilities_state.dart @@ -1,10 +1,15 @@ import 'package:freezed_annotation/freezed_annotation.dart'; +import '../../../../../access/user_role.dart'; +import '../../../../../api/marianumconnect/queries/get_capabilities/guardian_child.dart'; + part 'capabilities_state.freezed.dart'; part 'capabilities_state.g.dart'; @freezed abstract class CapabilitiesState with _$CapabilitiesState { + const CapabilitiesState._(); + const factory CapabilitiesState({ @Default(false) bool viewForeignTimetables, @Default(false) bool pushNotifications, @@ -12,8 +17,10 @@ abstract class CapabilitiesState with _$CapabilitiesState { // client-side clamp; the (server-narrowed) school year alone governs. int? timetablePastDays, int? timetableFutureDays, - // LDAP user type ('TEACHER' | 'STUDENT' | 'STAFF'); null = unknown. + // Wire value of the user type; read it through [role]. String? userType, + // Students a guardian may see; empty for all other accounts. + @Default([]) List children, // Whether a capability response (or a definitive failure) has been // observed at least once this session. Lets the UI distinguish "still // unknown" from "confirmed not allowed". @@ -22,4 +29,6 @@ abstract class CapabilitiesState with _$CapabilitiesState { factory CapabilitiesState.fromJson(Map json) => _$CapabilitiesStateFromJson(json); + + UserRole get role => UserRole.parse(userType); } diff --git a/lib/state/app/modules/capabilities/bloc/capabilities_state.freezed.dart b/lib/state/app/modules/capabilities/bloc/capabilities_state.freezed.dart index 538128a..23fd9f0 100644 --- a/lib/state/app/modules/capabilities/bloc/capabilities_state.freezed.dart +++ b/lib/state/app/modules/capabilities/bloc/capabilities_state.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 'capabilities_state.dart'; @@ -9,19 +9,14 @@ part of 'capabilities_state.dart'; // FreezedGenerator // ************************************************************************** +// GENERATED CODE - DO NOT MODIFY BY HAND // dart format off T _$identity(T value) => value; /// @nodoc mixin _$CapabilitiesState { - bool get viewForeignTimetables; bool get pushNotifications;// Days into the past/future the timetable may be scrolled. Null = no -// client-side clamp; the (server-narrowed) school year alone governs. - int? get timetablePastDays; int? get timetableFutureDays;// LDAP user type ('TEACHER' | 'STUDENT' | 'STAFF'); null = unknown. - String? get userType;// Whether a capability response (or a definitive failure) has been -// observed at least once this session. Lets the UI distinguish "still -// unknown" from "confirmed not allowed". - bool get loaded; + bool get viewForeignTimetables; bool get pushNotifications; int? get timetablePastDays; int? get timetableFutureDays; String? get userType; List get children; bool get loaded; /// Create a copy of CapabilitiesState /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -34,16 +29,21 @@ $CapabilitiesStateCopyWith get copyWith => _$CapabilitiesStat @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is CapabilitiesState&&(identical(other.viewForeignTimetables, viewForeignTimetables) || other.viewForeignTimetables == viewForeignTimetables)&&(identical(other.pushNotifications, pushNotifications) || other.pushNotifications == pushNotifications)&&(identical(other.timetablePastDays, timetablePastDays) || other.timetablePastDays == timetablePastDays)&&(identical(other.timetableFutureDays, timetableFutureDays) || other.timetableFutureDays == timetableFutureDays)&&(identical(other.userType, userType) || other.userType == userType)&&(identical(other.loaded, loaded) || other.loaded == loaded)); + final _this = this as CapabilitiesState; + return identical(this, other) || (other.runtimeType == runtimeType&&other is CapabilitiesState&&(identical(other.viewForeignTimetables, _this.viewForeignTimetables) || other.viewForeignTimetables == _this.viewForeignTimetables)&&(identical(other.pushNotifications, _this.pushNotifications) || other.pushNotifications == _this.pushNotifications)&&(identical(other.timetablePastDays, _this.timetablePastDays) || other.timetablePastDays == _this.timetablePastDays)&&(identical(other.timetableFutureDays, _this.timetableFutureDays) || other.timetableFutureDays == _this.timetableFutureDays)&&(identical(other.userType, _this.userType) || other.userType == _this.userType)&&const DeepCollectionEquality().equals(other.children, _this.children)&&(identical(other.loaded, _this.loaded) || other.loaded == _this.loaded)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,viewForeignTimetables,pushNotifications,timetablePastDays,timetableFutureDays,userType,loaded); +int get hashCode { + final _this = this as CapabilitiesState; + return Object.hash(runtimeType,_this.viewForeignTimetables,_this.pushNotifications,_this.timetablePastDays,_this.timetableFutureDays,_this.userType,const DeepCollectionEquality().hash(_this.children),_this.loaded); +} @override String toString() { - return 'CapabilitiesState(viewForeignTimetables: $viewForeignTimetables, pushNotifications: $pushNotifications, timetablePastDays: $timetablePastDays, timetableFutureDays: $timetableFutureDays, userType: $userType, loaded: $loaded)'; + final _this = this as CapabilitiesState; + return 'CapabilitiesState(viewForeignTimetables: ${_this.viewForeignTimetables}, pushNotifications: ${_this.pushNotifications}, timetablePastDays: ${_this.timetablePastDays}, timetableFutureDays: ${_this.timetableFutureDays}, userType: ${_this.userType}, children: ${_this.children}, loaded: ${_this.loaded})'; } @@ -54,7 +54,7 @@ abstract mixin class $CapabilitiesStateCopyWith<$Res> { factory $CapabilitiesStateCopyWith(CapabilitiesState value, $Res Function(CapabilitiesState) _then) = _$CapabilitiesStateCopyWithImpl; @useResult $Res call({ - bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, bool loaded + bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, List children, bool loaded }); @@ -71,14 +71,15 @@ class _$CapabilitiesStateCopyWithImpl<$Res> /// Create a copy of CapabilitiesState /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? viewForeignTimetables = null,Object? pushNotifications = null,Object? timetablePastDays = freezed,Object? timetableFutureDays = freezed,Object? userType = freezed,Object? loaded = null,}) { - return _then(_self.copyWith( +@pragma('vm:prefer-inline') @override $Res call({Object? viewForeignTimetables = null,Object? pushNotifications = null,Object? timetablePastDays = freezed,Object? timetableFutureDays = freezed,Object? userType = freezed,Object? children = null,Object? loaded = null,}) { + return _then(CapabilitiesState( viewForeignTimetables: null == viewForeignTimetables ? _self.viewForeignTimetables : viewForeignTimetables // ignore: cast_nullable_to_non_nullable as bool,pushNotifications: null == pushNotifications ? _self.pushNotifications : pushNotifications // ignore: cast_nullable_to_non_nullable as bool,timetablePastDays: freezed == timetablePastDays ? _self.timetablePastDays : timetablePastDays // ignore: cast_nullable_to_non_nullable as int?,timetableFutureDays: freezed == timetableFutureDays ? _self.timetableFutureDays : timetableFutureDays // ignore: cast_nullable_to_non_nullable as int?,userType: freezed == userType ? _self.userType : userType // ignore: cast_nullable_to_non_nullable -as String?,loaded: null == loaded ? _self.loaded : loaded // ignore: cast_nullable_to_non_nullable +as String?,children: null == children ? _self.children : children // ignore: cast_nullable_to_non_nullable +as List,loaded: null == loaded ? _self.loaded : loaded // ignore: cast_nullable_to_non_nullable as bool, )); } @@ -164,10 +165,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, bool loaded)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, List children, bool loaded)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _CapabilitiesState() when $default != null: -return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timetablePastDays,_that.timetableFutureDays,_that.userType,_that.loaded);case _: +return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timetablePastDays,_that.timetableFutureDays,_that.userType,_that.children,_that.loaded);case _: return orElse(); } @@ -185,10 +186,10 @@ return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timeta /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, bool loaded) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, List children, bool loaded) $default,) {final _that = this; switch (_that) { case _CapabilitiesState(): -return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timetablePastDays,_that.timetableFutureDays,_that.userType,_that.loaded);case _: +return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timetablePastDays,_that.timetableFutureDays,_that.userType,_that.children,_that.loaded);case _: throw StateError('Unexpected subclass'); } @@ -205,10 +206,10 @@ return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timeta /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, bool loaded)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, List children, bool loaded)? $default,) {final _that = this; switch (_that) { case _CapabilitiesState() when $default != null: -return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timetablePastDays,_that.timetableFutureDays,_that.userType,_that.loaded);case _: +return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timetablePastDays,_that.timetableFutureDays,_that.userType,_that.children,_that.loaded);case _: return null; } @@ -219,21 +220,22 @@ return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timeta /// @nodoc @JsonSerializable() -class _CapabilitiesState implements CapabilitiesState { - const _CapabilitiesState({this.viewForeignTimetables = false, this.pushNotifications = false, this.timetablePastDays, this.timetableFutureDays, this.userType, this.loaded = false}); +class _CapabilitiesState extends CapabilitiesState { + const _CapabilitiesState({this.viewForeignTimetables = false, this.pushNotifications = false, this.timetablePastDays, this.timetableFutureDays, this.userType, List children = const [], this.loaded = false}): _children = children,super._(); factory _CapabilitiesState.fromJson(Map json) => _$CapabilitiesStateFromJson(json); @override@JsonKey() final bool viewForeignTimetables; @override@JsonKey() final bool pushNotifications; -// Days into the past/future the timetable may be scrolled. Null = no -// client-side clamp; the (server-narrowed) school year alone governs. @override final int? timetablePastDays; @override final int? timetableFutureDays; -// LDAP user type ('TEACHER' | 'STUDENT' | 'STAFF'); null = unknown. @override final String? userType; -// Whether a capability response (or a definitive failure) has been -// observed at least once this session. Lets the UI distinguish "still -// unknown" from "confirmed not allowed". + final List _children; +@override@JsonKey() List get children { + if (_children is EqualUnmodifiableListView) return _children; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_children); +} + @override@JsonKey() final bool loaded; /// Create a copy of CapabilitiesState @@ -249,16 +251,18 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _CapabilitiesState&&(identical(other.viewForeignTimetables, viewForeignTimetables) || other.viewForeignTimetables == viewForeignTimetables)&&(identical(other.pushNotifications, pushNotifications) || other.pushNotifications == pushNotifications)&&(identical(other.timetablePastDays, timetablePastDays) || other.timetablePastDays == timetablePastDays)&&(identical(other.timetableFutureDays, timetableFutureDays) || other.timetableFutureDays == timetableFutureDays)&&(identical(other.userType, userType) || other.userType == userType)&&(identical(other.loaded, loaded) || other.loaded == loaded)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _CapabilitiesState&&(identical(other.viewForeignTimetables, viewForeignTimetables) || other.viewForeignTimetables == viewForeignTimetables)&&(identical(other.pushNotifications, pushNotifications) || other.pushNotifications == pushNotifications)&&(identical(other.timetablePastDays, timetablePastDays) || other.timetablePastDays == timetablePastDays)&&(identical(other.timetableFutureDays, timetableFutureDays) || other.timetableFutureDays == timetableFutureDays)&&(identical(other.userType, userType) || other.userType == userType)&&const DeepCollectionEquality().equals(other.children, _children)&&(identical(other.loaded, loaded) || other.loaded == loaded)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,viewForeignTimetables,pushNotifications,timetablePastDays,timetableFutureDays,userType,loaded); +int get hashCode { + return Object.hash(runtimeType,viewForeignTimetables,pushNotifications,timetablePastDays,timetableFutureDays,userType,const DeepCollectionEquality().hash(_children),loaded); +} @override String toString() { - return 'CapabilitiesState(viewForeignTimetables: $viewForeignTimetables, pushNotifications: $pushNotifications, timetablePastDays: $timetablePastDays, timetableFutureDays: $timetableFutureDays, userType: $userType, loaded: $loaded)'; + return 'CapabilitiesState(viewForeignTimetables: $viewForeignTimetables, pushNotifications: $pushNotifications, timetablePastDays: $timetablePastDays, timetableFutureDays: $timetableFutureDays, userType: $userType, children: $children, loaded: $loaded)'; } @@ -269,7 +273,7 @@ abstract mixin class _$CapabilitiesStateCopyWith<$Res> implements $CapabilitiesS factory _$CapabilitiesStateCopyWith(_CapabilitiesState value, $Res Function(_CapabilitiesState) _then) = __$CapabilitiesStateCopyWithImpl; @override @useResult $Res call({ - bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, bool loaded + bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, List children, bool loaded }); @@ -286,14 +290,15 @@ class __$CapabilitiesStateCopyWithImpl<$Res> /// Create a copy of CapabilitiesState /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? viewForeignTimetables = null,Object? pushNotifications = null,Object? timetablePastDays = freezed,Object? timetableFutureDays = freezed,Object? userType = freezed,Object? loaded = null,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? viewForeignTimetables = null,Object? pushNotifications = null,Object? timetablePastDays = freezed,Object? timetableFutureDays = freezed,Object? userType = freezed,Object? children = null,Object? loaded = null,}) { return _then(_CapabilitiesState( viewForeignTimetables: null == viewForeignTimetables ? _self.viewForeignTimetables : viewForeignTimetables // ignore: cast_nullable_to_non_nullable as bool,pushNotifications: null == pushNotifications ? _self.pushNotifications : pushNotifications // ignore: cast_nullable_to_non_nullable as bool,timetablePastDays: freezed == timetablePastDays ? _self.timetablePastDays : timetablePastDays // ignore: cast_nullable_to_non_nullable as int?,timetableFutureDays: freezed == timetableFutureDays ? _self.timetableFutureDays : timetableFutureDays // ignore: cast_nullable_to_non_nullable as int?,userType: freezed == userType ? _self.userType : userType // ignore: cast_nullable_to_non_nullable -as String?,loaded: null == loaded ? _self.loaded : loaded // ignore: cast_nullable_to_non_nullable +as String?,children: null == children ? _self._children : children // ignore: cast_nullable_to_non_nullable +as List,loaded: null == loaded ? _self.loaded : loaded // ignore: cast_nullable_to_non_nullable as bool, )); } diff --git a/lib/state/app/modules/capabilities/bloc/capabilities_state.g.dart b/lib/state/app/modules/capabilities/bloc/capabilities_state.g.dart index c50fed3..e9a7d1c 100644 --- a/lib/state/app/modules/capabilities/bloc/capabilities_state.g.dart +++ b/lib/state/app/modules/capabilities/bloc/capabilities_state.g.dart @@ -13,6 +13,11 @@ _CapabilitiesState _$CapabilitiesStateFromJson(Map json) => timetablePastDays: (json['timetablePastDays'] as num?)?.toInt(), timetableFutureDays: (json['timetableFutureDays'] as num?)?.toInt(), userType: json['userType'] as String?, + children: + (json['children'] as List?) + ?.map((e) => GuardianChild.fromJson(e as Map)) + .toList() ?? + const [], loaded: json['loaded'] as bool? ?? false, ); @@ -23,5 +28,6 @@ Map _$CapabilitiesStateToJson(_CapabilitiesState instance) => 'timetablePastDays': instance.timetablePastDays, 'timetableFutureDays': instance.timetableFutureDays, 'userType': instance.userType, + 'children': instance.children, 'loaded': instance.loaded, }; 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..5187521 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 '../../../../../access/access_requirement.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'; @@ -43,6 +44,11 @@ class ChatListBloc }); } + @override + Set get requirements => const { + AccessRequirement.nextcloud, + }; + @override ChatListRepository repository() => ChatListRepository(); @@ -73,6 +79,7 @@ class ChatListBloc } Future refresh({bool renew = true, bool silent = false}) async { + if (!requirementsMet) return; if (!silent) add(RefetchStarted()); Object? capturedError; try { diff --git a/lib/state/app/modules/children/child_selection_cubit.dart b/lib/state/app/modules/children/child_selection_cubit.dart new file mode 100644 index 0000000..4f8de18 --- /dev/null +++ b/lib/state/app/modules/children/child_selection_cubit.dart @@ -0,0 +1,33 @@ +import 'package:hydrated_bloc/hydrated_bloc.dart'; + +import '../../../../api/marianumconnect/queries/get_capabilities/guardian_child.dart'; + +/// The child a guardian is currently looking at. Shared by every module that +/// shows per-child data (timetable, absence report, later messages), so +/// switching the child in one place switches it everywhere. +class ChildSelectionCubit extends HydratedCubit { + ChildSelectionCubit() : super(null); + + void select(String childId) => emit(childId); + + Future reset() async => emit(null); + + @override + String? fromJson(Map json) => json['childId'] as String?; + + @override + Map? toJson(String? state) => {'childId': state}; +} + +/// The selected child if it is still linked, otherwise the first one. Null +/// when there are no children. +GuardianChild? effectiveChild( + List children, + String? selectedId, +) { + if (children.isEmpty) return null; + for (final child in children) { + if (child.id == selectedId) return child; + } + return children.first; +} 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 deleted file mode 100644 index 12b7c78..0000000 --- a/lib/state/app/modules/foreign_timetable/bloc/foreign_timetable_bloc.dart +++ /dev/null @@ -1,204 +0,0 @@ -import 'dart:developer'; - -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 '../../../infrastructure/loadable_state/loadable_state.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 '../../timetable/bloc/timetable_event.dart'; -import '../../timetable/bloc/timetable_state.dart'; -import '../repository/foreign_timetable_repository.dart'; - -/// Drives a foreign element's timetable. Mirrors `TimetableBloc`'s week-loading -/// and navigation but loads weeks from the element endpoint, carries no custom -/// events, and does not persist (page-scoped, recreated per element). Reuses -/// [TimetableState] verbatim so the render pipeline is unchanged; `customEvents` -/// stays null (the foreign view's `isReady` predicate ignores it). -class ForeignTimetableBloc - extends - LoadableHydratedBloc< - TimetableEvent, - TimetableState, - ForeignTimetableRepository - > { - - final TimetableElementType type; - // Named `elementId` rather than `id` to avoid shadowing HydratedMixin's - // `String get id` (the storage key), which a plain `int id` would illegally - // override. - final int elementId; - final String title; - - DateTime _lastWeekRequestStart = DateTime.fromMillisecondsSinceEpoch(0); - - ForeignTimetableBloc({ - required this.type, - required this.elementId, - required this.title, - }); - - @override - ForeignTimetableRepository repository() => ForeignTimetableRepository(); - - @override - TimetableState fromNothing() { - final reference = DateTime.now().addDays(2); - return TimetableState( - startDate: _startOfWeek(reference), - endDate: _endOfWeek(reference), - ); - } - - // Persistence disabled: page-scoped and element-specific, nothing worth - // restoring. toJson returns null so nothing is written; fromJson starts fresh. - @override - Map? toJson(LoadableState state) => null; - - @override - LoadableState fromJson(Map json) => - const LoadableState( - isLoading: true, - data: null, - lastFetch: null, - reFetch: null, - error: null, - ); - - @override - TimetableState fromStorage(Map json) => fromNothing(); - - @override - Map? toStorage(TimetableState state) => null; - - @override - Future gatherData() async { - final initial = innerState ?? fromNothing(); - - Object? firstError; - void recordError(Object e) { - firstError ??= e; - } - - await Future.wait([ - _loadCurrentWeek(initial.startDate, initial.endDate, onError: recordError), - _loadStaticReferenceData(onError: recordError), - ]); - - if (firstError != null) throw firstError!; - - add(DataGathered((s) => s)); - _prefetchAdjacentWeeks(initial.startDate, initial.endDate); - } - - void changeWeek(DateTime startDate, DateTime endDate) { - 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); - } - - void resetWeek() { - final reference = DateTime.now().addDays(2); - changeWeek(_startOfWeek(reference), _endOfWeek(reference)); - } - - void refresh() => fetch(); - - Future _loadCurrentWeek( - DateTime startDate, - DateTime endDate, { - void Function(Object)? onError, - }) async { - final requestStart = DateTime.now(); - _lastWeekRequestStart = requestStart; - try { - final week = await repo.data.getElementWeek( - type, - elementId, - startDate, - endDate, - onError: onError, - ); - if (_lastWeekRequestStart.isAfter(requestStart)) return; - _writeWeekToCache(startDate, week); - } catch (e) { - log('getElementWeek error for $startDate–$endDate: $e'); - onError?.call(e); - } - } - - Future _loadStaticReferenceData({ - void Function(Object)? onError, - }) async { - try { - final (rooms, subjects, schoolHolidays, schoolyear) = await ( - repo.data.getRooms(onError: onError), - repo.data.getSubjects(onError: onError), - repo.data.getSchoolHolidays(onError: onError), - repo.data.getCurrentSchoolyear(onError: onError), - ).wait; - - add( - Emit( - (s) => s.copyWith( - rooms: rooms, - subjects: subjects, - schoolHolidays: schoolHolidays, - schoolyear: schoolyear, - dataVersion: s.dataVersion + 1, - ), - ), - ); - } catch (e) { - onError?.call(e); - } - - try { - final timegrid = await repo.data.getTimegrid(); - add( - Emit( - (s) => s.copyWith(timegrid: timegrid, dataVersion: s.dataVersion + 1), - ), - ); - } catch (_) { - // Timegrid load failure falls back to a hardcoded schedule in the UI. - } - } - - void _prefetchAdjacentWeeks(DateTime start, DateTime end) { - _prefetchWeek(start.subtractDays(7), end.subtractDays(7)); - _prefetchWeek(start.addDays(7), end.addDays(7)); - } - - void _prefetchWeek(DateTime start, DateTime end) { - repo.data - .getElementWeek(type, elementId, start, end) - .then((week) => _writeWeekToCache(start, week)) - .catchError((_) {}); - } - - 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); - } - - static DateTime _endOfWeek(DateTime reference) { - final friday = reference.addDays( - DateTime.daysPerWeek - reference.weekday - 2, - ); - return DateTime(friday.year, friday.month, friday.day); - } -} diff --git a/lib/state/app/modules/foreign_timetable/data_provider/foreign_timetable_data_provider.dart b/lib/state/app/modules/foreign_timetable/data_provider/foreign_timetable_data_provider.dart deleted file mode 100644 index 304065f..0000000 --- a/lib/state/app/modules/foreign_timetable/data_provider/foreign_timetable_data_provider.dart +++ /dev/null @@ -1,64 +0,0 @@ -import '../../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart'; -import '../../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_get_element_week.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_schoolyear/timetable_get_schoolyear_response.dart'; -import '../../../../../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart'; -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 '../../timetable/data_provider/timetable_data_provider.dart'; - -/// Data access for a foreign element's timetable. The week comes from the -/// element-specific endpoint; all reference data (rooms/subjects/holidays/ -/// school year/timegrid) is school-wide, so it delegates to the existing -/// [TimetableDataProvider] (which caches it). Custom events are intentionally -/// absent — they are user-private. -class ForeignTimetableDataProvider { - final TimetableDataProvider _base; - - ForeignTimetableDataProvider([TimetableDataProvider? base]) - : _base = base ?? TimetableDataProvider(); - - Future getElementWeek( - TimetableElementType type, - int id, - DateTime startDate, - DateTime endDate, { - void Function(Object)? onError, - }) async { - try { - return await TimetableGetElementWeek().run( - type: type, - id: id, - from: startDate, - until: endDate, - ); - } catch (e) { - onError?.call(e); - rethrow; - } - } - - Future getRooms({ - void Function(Object)? onError, - bool renew = false, - }) => _base.getRooms(onError: onError, renew: renew); - - Future getSubjects({ - void Function(Object)? onError, - bool renew = false, - }) => _base.getSubjects(onError: onError, renew: renew); - - Future getSchoolHolidays({ - void Function(Object)? onError, - bool renew = false, - }) => _base.getSchoolHolidays(onError: onError, renew: renew); - - Future getCurrentSchoolyear({ - void Function(Object)? onError, - bool renew = false, - }) => _base.getCurrentSchoolyear(onError: onError, renew: renew); - - Future getTimegrid({bool renew = false}) => - _base.getTimegrid(renew: renew); -} diff --git a/lib/state/app/modules/foreign_timetable/repository/foreign_timetable_repository.dart b/lib/state/app/modules/foreign_timetable/repository/foreign_timetable_repository.dart deleted file mode 100644 index f8f6c4a..0000000 --- a/lib/state/app/modules/foreign_timetable/repository/foreign_timetable_repository.dart +++ /dev/null @@ -1,12 +0,0 @@ -import '../../../infrastructure/repository/repository.dart'; -import '../../timetable/bloc/timetable_state.dart'; -import '../data_provider/foreign_timetable_data_provider.dart'; - -class ForeignTimetableRepository extends Repository { - final ForeignTimetableDataProvider _provider; - - ForeignTimetableRepository([ForeignTimetableDataProvider? provider]) - : _provider = provider ?? ForeignTimetableDataProvider(); - - ForeignTimetableDataProvider get data => _provider; -} 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..0e8304f 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 '../../../../../session/session_manager.dart'; import 'nextcloud_capabilities_state.dart'; /// Holds the current user's Nextcloud `files_sharing` capabilities. Hydrated so @@ -59,6 +60,7 @@ 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; diff --git a/lib/state/app/modules/timetable/bloc/timetable_bloc.dart b/lib/state/app/modules/timetable/bloc/timetable_bloc.dart index 8519361..b6c3f74 100644 --- a/lib/state/app/modules/timetable/bloc/timetable_bloc.dart +++ b/lib/state/app/modules/timetable/bloc/timetable_bloc.dart @@ -4,12 +4,17 @@ import '../../../../../api/marianumconnect/queries/timetable_custom_events/custo import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; import '../../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; import '../../../../../extensions/date_time.dart'; +import '../../../infrastructure/loadable_state/loadable_state.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/timetable_repository.dart'; +import '../subject/timetable_subject.dart'; import 'timetable_event.dart'; import 'timetable_state.dart'; +/// Drives one [TimetableSubject]'s plan. The same class serves the own plan +/// and foreign element plans; everything subject-specific (endpoint, +/// persistence, custom events) is derived from [subject]. class TimetableBloc extends LoadableHydratedBloc< @@ -17,6 +22,13 @@ class TimetableBloc TimetableState, TimetableRepository > { + final TimetableSubject subject; + + TimetableBloc({required this.subject}); + + @override + String get id => subject.storageId; + DateTime _lastWeekRequestStart = DateTime.fromMillisecondsSinceEpoch(0); /// Set by [retry] to force the next [gatherData] to bypass cache freshness @@ -59,8 +71,25 @@ class TimetableBloc @override Map? toStorage(TimetableState state) => state.toJson(); + @override + Map? toJson(LoadableState state) => + subject.persistent ? super.toJson(state) : null; + + @override + LoadableState fromJson(Map json) => + subject.persistent + ? super.fromJson(json) + : const LoadableState( + isLoading: true, + data: null, + lastFetch: null, + reFetch: null, + error: null, + ); + @override Future gatherData() async { + if (subject is NoTimetable) return; final initial = innerState ?? fromNothing(); final renew = _forceRenew; _forceRenew = false; @@ -75,10 +104,10 @@ class TimetableBloc initial.startDate, initial.endDate, onError: recordError, - renew: renew, ), _loadStaticReferenceData(onError: recordError, renew: renew), - _loadCustomEvents(onError: recordError, renew: renew), + if (subject.supportsCustomEvents) + _loadCustomEvents(onError: recordError, renew: renew), ]); if (firstError != null) throw firstError!; @@ -102,17 +131,28 @@ class TimetableBloc void refresh() => fetch(); + /// Custom events belong to the signed-in user's own plan only — never to a + /// foreign plan or a guardian's view of a child. + void _requireCustomEvents() { + if (!subject.supportsCustomEvents) { + throw StateError('Custom events are not available for $subject'); + } + } + Future addCustomEvent(CustomTimetableEvent event) async { + _requireCustomEvents(); await repo.data.addCustomEvent(event); await _refreshCustomEvents(); } Future updateCustomEvent(String id, CustomTimetableEvent event) async { + _requireCustomEvents(); await repo.data.updateCustomEvent(id, event); await _refreshCustomEvents(); } Future removeCustomEvent(String id) async { + _requireCustomEvents(); await repo.data.removeCustomEvent(id); await _refreshCustomEvents(); } @@ -142,16 +182,15 @@ class TimetableBloc DateTime startDate, DateTime endDate, { void Function(Object)? onError, - bool renew = false, }) async { final requestStart = DateTime.now(); _lastWeekRequestStart = requestStart; try { final week = await repo.data.getWeek( + subject, startDate, endDate, onError: onError, - renew: renew, ); if (_lastWeekRequestStart.isAfter(requestStart)) return; _writeWeekToCache(startDate, week); @@ -237,7 +276,7 @@ class TimetableBloc void _prefetchWeek(DateTime start, DateTime end) { repo.data - .getWeek(start, end) + .getWeek(subject, start, end) .then((week) => _writeWeekToCache(start, week)) .catchError((_) {}); } @@ -265,3 +304,11 @@ class TimetableBloc return DateTime(friday.year, friday.month, friday.day); } } + +/// Type token for a page-scoped plan (e.g. a foreign element). Carries no +/// logic of its own; the distinct type keeps a page-local provider from +/// shadowing the app-wide [TimetableBloc] that sheets and root-navigator pages +/// (subject colours, custom events) read. +final class ScopedTimetableBloc extends TimetableBloc { + ScopedTimetableBloc({required super.subject}); +} diff --git a/lib/state/app/modules/timetable/bloc/timetable_state.dart b/lib/state/app/modules/timetable/bloc/timetable_state.dart index df00dd7..78fea8c 100644 --- a/lib/state/app/modules/timetable/bloc/timetable_state.dart +++ b/lib/state/app/modules/timetable/bloc/timetable_state.dart @@ -40,9 +40,12 @@ abstract class TimetableState with _$TimetableState { Iterable getAllKnownLessons() => weekCache.values.expand((response) => response.entries); - bool get hasReferenceData => + /// Whether the calendar has everything it needs to render. Custom events + /// only exist for subjects that support them; requiring them elsewhere would + /// keep foreign plans loading forever. + bool isReady({required bool needsCustomEvents}) => rooms != null && subjects != null && schoolHolidays != null && - customEvents != null; + (!needsCustomEvents || customEvents != null); } diff --git a/lib/state/app/modules/timetable/data_provider/timetable_data_provider.dart b/lib/state/app/modules/timetable/data_provider/timetable_data_provider.dart index dd38d2c..5b4f2b5 100644 --- a/lib/state/app/modules/timetable/data_provider/timetable_data_provider.dart +++ b/lib/state/app/modules/timetable/data_provider/timetable_data_provider.dart @@ -4,6 +4,8 @@ import '../../../../../api/marianumconnect/queries/timetable_custom_events/timet import '../../../../../api/marianumconnect/queries/timetable_custom_events/timetable_custom_events_cache.dart'; import '../../../../../api/marianumconnect/queries/timetable_custom_events/timetable_custom_events_remove.dart'; import '../../../../../api/marianumconnect/queries/timetable_custom_events/timetable_custom_events_update.dart'; +import '../../../../../api/marianumconnect/queries/timetable_get_child_week/timetable_get_child_week.dart'; +import '../../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_get_element_week.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'; @@ -21,19 +23,43 @@ import '../../../../../api/marianumconnect/queries/timetable_subject_colors/time import '../../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; import '../../../../../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart'; import '../../../../../api/request_cache.dart'; +import '../subject/timetable_subject.dart'; /// Pulls the timetable from the Marianum-Connect mobile API. Each endpoint is /// its own HTTP call; this provider exposes the lazy futures so the bloc can -/// chain them without seeing the dio layer. +/// chain them without seeing the dio layer. Only the week depends on the +/// [TimetableSubject]; the reference data is school-wide. class TimetableDataProvider { + /// The endpoint serving [subject]'s week. Shared with the widget background + /// isolate, which has no bloc. + static Future fetchWeek( + TimetableSubject subject, { + required DateTime from, + required DateTime until, + }) => switch (subject) { + OwnTimetable() => TimetableGetWeek().run(from: from, until: until), + ElementTimetable(:final element) => TimetableGetElementWeek().run( + type: element.type, + id: element.id, + from: from, + until: until, + ), + ChildTimetable(:final childId) => TimetableGetChildWeek().run( + childId: childId, + from: from, + until: until, + ), + NoTimetable() => throw StateError('No timetable subject'), + }; + Future getWeek( + TimetableSubject subject, DateTime startDate, DateTime endDate, { void Function(Object)? onError, - bool renew = false, }) async { try { - return await TimetableGetWeek().run(from: startDate, until: endDate); + return await fetchWeek(subject, from: startDate, until: endDate); } catch (e) { onError?.call(e); rethrow; diff --git a/lib/state/app/modules/timetable/policy/timetable_policy.dart b/lib/state/app/modules/timetable/policy/timetable_policy.dart new file mode 100644 index 0000000..7e83a78 --- /dev/null +++ b/lib/state/app/modules/timetable/policy/timetable_policy.dart @@ -0,0 +1,54 @@ +import '../../../../../access/user_role.dart'; +import '../../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart'; +import '../../capabilities/bloc/capabilities_state.dart'; +import '../subject/timetable_subject.dart'; + +/// What the timetable view offers for a given subject. Resolved in one place +/// so the view never branches on roles or subject types itself. +class TimetablePolicy { + final bool canManageCustomEvents; + final bool canEditSubjectColors; + final bool showClassInsteadOfTeacher; + final bool canOpenForeign; + + const TimetablePolicy({ + required this.canManageCustomEvents, + required this.canEditSubjectColors, + required this.showClassInsteadOfTeacher, + required this.canOpenForeign, + }); + + static TimetablePolicy resolve({ + required TimetableSubject subject, + required CapabilitiesState capabilities, + }) => switch (subject) { + OwnTimetable() => TimetablePolicy( + canManageCustomEvents: true, + canEditSubjectColors: true, + showClassInsteadOfTeacher: capabilities.role == UserRole.teacher, + canOpenForeign: capabilities.viewForeignTimetables, + ), + // Subject colours are the viewer's own, global setting; editing them from + // a foreign plan would not refresh that plan, so it is not offered there. + ElementTimetable(:final element) => TimetablePolicy( + canManageCustomEvents: false, + canEditSubjectColors: false, + showClassInsteadOfTeacher: element.type == TimetableElementType.teacher, + canOpenForeign: capabilities.viewForeignTimetables, + ), + // Custom events are the child's private data; subject colours are the + // guardian's own and apply to this (primary) plan directly. + ChildTimetable() => TimetablePolicy( + canManageCustomEvents: false, + canEditSubjectColors: true, + showClassInsteadOfTeacher: false, + canOpenForeign: capabilities.viewForeignTimetables, + ), + NoTimetable() => const TimetablePolicy( + canManageCustomEvents: false, + canEditSubjectColors: false, + showClassInsteadOfTeacher: false, + canOpenForeign: false, + ), + }; +} diff --git a/lib/state/app/modules/timetable/primary/primary_subject_resolver.dart b/lib/state/app/modules/timetable/primary/primary_subject_resolver.dart new file mode 100644 index 0000000..119c2ba --- /dev/null +++ b/lib/state/app/modules/timetable/primary/primary_subject_resolver.dart @@ -0,0 +1,18 @@ +import '../../../../../api/marianumconnect/queries/get_capabilities/guardian_child.dart'; +import '../../../../../session/session.dart'; +import '../../children/child_selection_cubit.dart'; +import '../subject/timetable_subject.dart'; + +/// Whose plan the timetable tab shows for the active session. +TimetableSubject resolvePrimarySubject({ + required Session? session, + required List children, + required String? selectedChildId, +}) => switch (session) { + null => const NoTimetable(), + CredentialSession() => const OwnTimetable(), + GuardianSession() => switch (effectiveChild(children, selectedChildId)) { + null => const NoTimetable(), + final child => ChildTimetable(child.id), + }, +}; diff --git a/lib/state/app/modules/timetable/primary/primary_timetable_scope.dart b/lib/state/app/modules/timetable/primary/primary_timetable_scope.dart new file mode 100644 index 0000000..a508a82 --- /dev/null +++ b/lib/state/app/modules/timetable/primary/primary_timetable_scope.dart @@ -0,0 +1,81 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../../session/session_manager.dart'; +import '../../account/bloc/account_bloc.dart'; +import '../../account/bloc/account_state.dart'; +import '../../capabilities/bloc/capabilities_cubit.dart'; +import '../../capabilities/bloc/capabilities_state.dart'; +import '../../children/child_selection_cubit.dart'; +import '../bloc/timetable_bloc.dart'; +import '../subject/timetable_subject.dart'; +import 'primary_subject_resolver.dart'; + +/// Provides the app-wide [TimetableBloc] for the session's primary subject +/// (own plan, or the selected child for guardians) and swaps it for a fresh +/// instance when that subject changes. +/// +/// Sits above MaterialApp so root-navigator pages (subject colours, custom +/// events) reach it. The widget subtree is kept on a swap — only the provided +/// instance changes, which BlocBuilder/BlocListener pick up — so switching +/// the child does not reset the navigation. A new instance per subject (rather +/// than retargeting one bloc) keeps late responses for the previous child out +/// of the new child's week cache. +class PrimaryTimetableScope extends StatefulWidget { + final Widget child; + + const PrimaryTimetableScope({required this.child, super.key}); + + @override + State createState() => _PrimaryTimetableScopeState(); +} + +class _PrimaryTimetableScopeState extends State { + late TimetableBloc _bloc; + + @override + void initState() { + super.initState(); + _bloc = TimetableBloc(subject: _resolve()); + } + + @override + void dispose() { + _bloc.close(); + super.dispose(); + } + + TimetableSubject _resolve() => resolvePrimarySubject( + session: SessionManager().current, + children: context.read().state.children, + selectedChildId: context.read().state, + ); + + void _sync() { + final subject = _resolve(); + if (subject == _bloc.subject) return; + final previous = _bloc; + setState(() => _bloc = TimetableBloc(subject: subject)); + // Dependents re-subscribe during the next build; close afterwards. + WidgetsBinding.instance.addPostFrameCallback((_) => previous.close()); + } + + @override + Widget build(BuildContext context) => MultiBlocListener( + listeners: [ + BlocListener( + listenWhen: (a, b) => a.status != b.status, + listener: (_, _) => _sync(), + ), + BlocListener( + listenWhen: (a, b) => a.children != b.children, + listener: (_, _) => _sync(), + ), + BlocListener(listener: (_, _) => _sync()), + ], + child: BlocProvider.value( + value: _bloc, + child: widget.child, + ), + ); +} diff --git a/lib/state/app/modules/timetable/subject/timetable_subject.dart b/lib/state/app/modules/timetable/subject/timetable_subject.dart new file mode 100644 index 0000000..abee554 --- /dev/null +++ b/lib/state/app/modules/timetable/subject/timetable_subject.dart @@ -0,0 +1,110 @@ +import '../../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart'; + +/// Whose timetable a [TimetableBloc] shows. The render pipeline is identical +/// for every subject; only the week endpoint, persistence and the +/// user-private extras (custom events) differ. +sealed class TimetableSubject { + const TimetableSubject(); + + /// Suffix of the hydrated storage slot. Must be unique per subject so + /// subjects never overwrite each other's cached weeks. + String get storageId; + + /// Whether the bloc keeps its state across app restarts. + bool get persistent; + + /// Custom events are user-private and only exist for the own plan. + bool get supportsCustomEvents; +} + +/// The signed-in user's own plan (`timetable/me`). +final class OwnTimetable extends TimetableSubject { + const OwnTimetable(); + + // Empty on purpose: keeps the pre-existing storage slot "TimetableBloc", so + // updating the app does not drop the cached weeks. + @override + String get storageId => ''; + + @override + bool get persistent => true; + + @override + bool get supportsCustomEvents => true; + + @override + bool operator ==(Object other) => other is OwnTimetable; + + @override + int get hashCode => (OwnTimetable).hashCode; +} + +/// A foreign element picked by the user (teacher, room, class, student). +final class ElementTimetable extends TimetableSubject { + final TimetableElementRef element; + + const ElementTimetable(this.element); + + @override + String get storageId => 'element-${element.type.name}-${element.id}'; + + @override + bool get persistent => false; + + @override + bool get supportsCustomEvents => false; + + @override + bool operator ==(Object other) => + other is ElementTimetable && + other.element.type == element.type && + other.element.id == element.id; + + @override + int get hashCode => Object.hash(element.type, element.id); +} + +/// A guardian's child (`timetable/child/{id}`). Kept across restarts per +/// child, so switching between siblings shows the cached plan immediately. +final class ChildTimetable extends TimetableSubject { + final String childId; + + const ChildTimetable(this.childId); + + @override + String get storageId => 'child-$childId'; + + @override + bool get persistent => true; + + @override + bool get supportsCustomEvents => false; + + @override + bool operator ==(Object other) => + other is ChildTimetable && other.childId == childId; + + @override + int get hashCode => childId.hashCode; +} + +/// No plan to show: signed out, or a guardian without (known) children. The +/// bloc loads nothing; the view explains why. +final class NoTimetable extends TimetableSubject { + const NoTimetable(); + + @override + String get storageId => 'none'; + + @override + bool get persistent => false; + + @override + bool get supportsCustomEvents => false; + + @override + bool operator ==(Object other) => other is NoTimetable; + + @override + int get hashCode => (NoTimetable).hashCode; +} 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..bfb3a92 --- /dev/null +++ b/lib/utils/random_id.dart @@ -0,0 +1,10 @@ +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(); +} 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/guardian_login_controller.dart b/lib/view/login/guardian_login_controller.dart new file mode 100644 index 0000000..dc6780c --- /dev/null +++ b/lib/view/login/guardian_login_controller.dart @@ -0,0 +1,196 @@ +import 'dart:developer'; + +import 'package:flutter/foundation.dart'; + +import '../../api/demo/demo_mode.dart'; +import '../../api/errors/error_mapper.dart'; +import '../../api/marianumconnect/auth/device_token_name.dart'; +import '../../api/marianumconnect/queries/auth_guardian/auth_guardian_request.dart'; +import '../../api/marianumconnect/queries/auth_guardian/auth_guardian_verify.dart'; +import '../../api/marianumconnect/queries/auth_guardian/guardian_login_exception.dart'; +import '../../auth_link/device_binding.dart'; +import '../../auth_link/guardian_login_link.dart'; +import '../../auth_link/pending_guardian_request.dart'; +import '../../session/session.dart'; +import '../../session/session_manager.dart'; +import '../../widget_data/widget_sync.dart'; + +enum GuardianLoginStep { enterEmail, enterCode } + +/// Drives the passwordless guardian login: request a mail, then finish with +/// the mailed code or link. The running request is persisted so the flow +/// survives the app being killed while the user reads the mail. +class GuardianLoginController extends ChangeNotifier { + final AuthGuardianRequest _request; + final AuthGuardianVerify _verify; + final PendingGuardianRequestStore _store; + final Future Function(Session session) _signIn; + final Future Function() _tokenName; + final DateTime Function() _now; + + GuardianLoginController({ + AuthGuardianRequest? request, + AuthGuardianVerify? verify, + PendingGuardianRequestStore store = const PendingGuardianRequestStore(), + Future Function(Session session)? signIn, + Future Function()? tokenName, + DateTime Function()? now, + }) : _request = request ?? AuthGuardianRequest(), + _verify = verify ?? AuthGuardianVerify(), + _store = store, + _signIn = signIn ?? _defaultSignIn, + _tokenName = tokenName ?? DeviceTokenName.resolve, + _now = now ?? DateTime.now; + + GuardianLoginStep _step = GuardianLoginStep.enterEmail; + PendingGuardianRequest? _pending; + bool _loading = false; + String? _errorMessage; + String? _errorDetails; + + GuardianLoginStep get step => _step; + PendingGuardianRequest? get pending => _pending; + bool get loading => _loading; + String? get errorMessage => _errorMessage; + String? get errorDetails => _errorDetails; + + bool canResend() { + final pending = _pending; + return pending != null && !_now().isBefore(pending.resendAvailableAt); + } + + /// Picks up a request started before the app was closed. + Future restore() async { + final stored = await _store.read(); + if (stored == null) return; + if (stored.isExpired(_now())) { + await _store.clear(); + return; + } + _pending = stored; + _step = GuardianLoginStep.enterCode; + notifyListeners(); + } + + /// Sends the login mail. Returns true when the user is already signed in + /// (demo address), false when the code step follows or the request failed. + Future requestCode(String email) async { + final normalized = email.trim().toLowerCase(); + if (DemoMode.matchesGuardian(normalized)) { + await _signIn(GuardianSession(email: normalized, isDemo: true)); + return true; + } + await _run(() async { + final secret = DeviceBinding.generateSecret(); + final response = await _request.run( + email: normalized, + deviceChallenge: DeviceBinding.challengeFor(secret), + tokenName: await _tokenName(), + ); + final pending = PendingGuardianRequest( + requestId: response.requestId, + email: normalized, + deviceSecret: secret, + expiresAt: response.expiresAt, + resendAvailableAt: response.resendAvailableAt, + codeLength: response.codeLength, + ); + await _store.write(pending); + _pending = pending; + _step = GuardianLoginStep.enterCode; + }); + return false; + } + + Future resend() async { + final pending = _pending; + if (pending == null || !canResend()) return; + await requestCode(pending.email); + } + + /// Mail clients and autofill may insert spaces into the code. + static String normalizeCode(String code) => + code.replaceAll(RegExp(r'\s'), ''); + + Future submitCode(String code) => _complete(code: normalizeCode(code)); + + /// Completes the login from a mail link. A link belonging to another + /// request (other device, or an older mail) cannot be verified here. + Future submitLink(GuardianLoginLink link) { + if (_pending?.requestId != link.requestId) { + _errorMessage = GuardianLoginException.messageFor( + GuardianLoginError.deviceMismatch, + ); + _errorDetails = null; + notifyListeners(); + return Future.value(false); + } + return _complete(linkToken: link.linkToken); + } + + /// Abandons the running request, e.g. to correct a mistyped address. + Future changeEmail() async { + await _store.clear(); + _pending = null; + _step = GuardianLoginStep.enterEmail; + _errorMessage = null; + _errorDetails = null; + notifyListeners(); + } + + Future _complete({String? code, String? linkToken}) async { + final pending = _pending; + if (pending == null) return false; + var signedIn = false; + await _run(() async { + await _verify.run( + requestId: pending.requestId, + deviceVerifier: pending.deviceSecret, + tokenName: await _tokenName(), + code: code, + linkToken: linkToken, + ); + await _store.clear(); + await _signIn(GuardianSession(email: pending.email)); + signedIn = true; + }); + return signedIn; + } + + Future _run(Future Function() body) async { + if (_loading) return; + _loading = true; + _errorMessage = null; + _errorDetails = null; + notifyListeners(); + try { + await body(); + } on GuardianLoginException catch (e) { + _errorMessage = e.userMessage; + _errorDetails = e.technicalDetails; + // These end the request for good; only a new mail helps. + if (e.error + case GuardianLoginError.requestExpired || + GuardianLoginError.requestConsumed || + GuardianLoginError.tooManyAttempts) { + await _store.clear(); + _pending = null; + _step = GuardianLoginStep.enterEmail; + } + } catch (e) { + log('Guardian login failed: $e'); + _errorMessage = errorToUserMessage(e); + _errorDetails = errorToTechnicalDetails(e); + } finally { + _loading = false; + notifyListeners(); + } + } + + static Future _defaultSignIn(Session session) async { + // Drop any widget snapshot of a previous account before the new one loads. + await WidgetSync.clear(); + await WidgetSync.triggerUpdate(); + await SessionManager().signIn(session); + } +} diff --git a/lib/view/login/login.dart b/lib/view/login/login.dart index d34a9f4..dbdab1d 100644 --- a/lib/view/login/login.dart +++ b/lib/view/login/login.dart @@ -3,6 +3,9 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../api/marianumconnect/marianumconnect_endpoint.dart' as mc; +import '../../auth_link/guardian_link_listener.dart'; +import '../../auth_link/guardian_login_link.dart'; import '../../background/widget_background_task.dart'; import '../../state/app/modules/account/bloc/account_bloc.dart'; import '../../state/app/modules/account/bloc/account_state.dart'; @@ -12,8 +15,11 @@ import '../../storage/settings.dart' as model; import '../../theming/light_app_theme.dart'; import '../../utils/haptics.dart'; import '../pages/settings/widgets/endpoint_picker.dart'; +import 'guardian_login_controller.dart'; import 'login_controller.dart'; import 'post_login_splash.dart'; +import 'widgets/guardian_login_card.dart'; +import 'widgets/login_audience_card.dart'; import 'widgets/login_branding.dart'; import 'widgets/login_card.dart'; @@ -28,12 +34,45 @@ class _LoginState extends State with SingleTickerProviderStateMixin { static const _marianumRed = LightAppTheme.marianumRed; final LoginController _controller = LoginController(); + final GuardianLoginController _guardianController = GuardianLoginController(); + late final Future _guardianRestored; + + /// Null while the user has not picked who is signing in. + LoginAudience? _audience; late final AnimationController _fade = AnimationController( vsync: this, duration: const Duration(milliseconds: 450), value: 1, ); + @override + void initState() { + super.initState(); + _guardianRestored = _guardianController.restore().then((_) { + if (!mounted || _guardianController.pending == null) return; + setState(() => _audience = LoginAudience.guardian); + }); + GuardianLinkListener.pending.addListener(_consumeGuardianLink); + _consumeGuardianLink(); + } + + /// A tapped mail link finishes the guardian login without typing the code. + Future _consumeGuardianLink() async { + final uri = GuardianLinkListener.pending.value; + if (uri == null) return; + GuardianLinkListener.clear(); + final link = GuardianLoginLink.parse( + uri, + apiBase: Uri.parse(mc.MarianumConnectEndpoint.current()), + ); + if (link == null) return; + await _guardianRestored; + if (!mounted) return; + setState(() => _audience = LoginAudience.guardian); + final signedIn = await _guardianController.submitLink(link); + if (signedIn && mounted) _onLoginSuccess(); + } + @override void didChangeDependencies() { super.didChangeDependencies(); @@ -43,8 +82,10 @@ class _LoginState extends State with SingleTickerProviderStateMixin { @override void dispose() { + GuardianLinkListener.pending.removeListener(_consumeGuardianLink); _fade.dispose(); _controller.dispose(); + _guardianController.dispose(); super.dispose(); } @@ -64,6 +105,20 @@ class _LoginState extends State with SingleTickerProviderStateMixin { }); } + Widget _buildCard() => switch (_audience) { + null => LoginAudienceCard( + onSelected: (choice) => setState(() => _audience = choice), + ), + LoginAudience.school => LoginCard( + controller: _controller, + onSuccess: _onLoginSuccess, + ), + LoginAudience.guardian => GuardianLoginCard( + controller: _guardianController, + onSuccess: _onLoginSuccess, + ), + }; + @override Widget build(BuildContext context) => Scaffold( backgroundColor: _marianumRed, @@ -89,12 +144,33 @@ class _LoginState extends State with SingleTickerProviderStateMixin { children: [ const LoginHeader(), const SizedBox(height: 28), - LoginCard( - controller: _controller, - onSuccess: _onLoginSuccess, - ), - const SizedBox(height: 18), - const LoginDisclaimer(), + _buildCard(), + if (_audience != null) + Padding( + padding: const EdgeInsets.only(top: 8), + // Leaving mid-request would drop the form that + // receives the result, so it is disabled then. + child: ListenableBuilder( + listenable: Listenable.merge([ + _controller, + _guardianController, + ]), + builder: (context, _) => TextButton.icon( + style: TextButton.styleFrom( + foregroundColor: Colors.white, + ), + icon: const Icon(Icons.arrow_back, size: 18), + label: const Text('Zurück zur Auswahl'), + onPressed: + _controller.loading || + _guardianController.loading + ? null + : () => setState(() => _audience = null), + ), + ), + ) + else + const SizedBox(height: 12), ], ), const Column( diff --git a/lib/view/login/login_controller.dart b/lib/view/login/login_controller.dart index cb25b36..0a4e167 100644 --- a/lib/view/login/login_controller.dart +++ b/lib/view/login/login_controller.dart @@ -10,7 +10,8 @@ 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 '../../session/session.dart'; +import '../../session/session_manager.dart'; import '../../widget_data/widget_sync.dart'; /// Outcome of a login attempt. @@ -51,25 +52,17 @@ 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; } 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( @@ -77,7 +70,9 @@ class LoginController extends ChangeNotifier { password: password, tokenName: await DeviceTokenName.resolve(), ); - await AccountData().setData(user, password); + await SessionManager().signIn( + CredentialSession(username: user, password: password), + ); // 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. @@ -87,7 +82,7 @@ class LoginController extends ChangeNotifier { return ncReady ? LoginResult.success : LoginResult.nextcloudLoginRequired; } catch (e) { log(e.toString()); - await AccountData().removeData(); + await SessionManager().signOut(); await const MarianumConnectTokenStorage().clear(); final isWrongCredentials = e is AuthException && e.statusCode == 401; _errorMessage = isWrongCredentials @@ -100,6 +95,16 @@ 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. + 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 @@ -107,7 +112,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; @@ -126,7 +131,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/login/widgets/guardian_login_card.dart b/lib/view/login/widgets/guardian_login_card.dart new file mode 100644 index 0000000..b910d63 --- /dev/null +++ b/lib/view/login/widgets/guardian_login_card.dart @@ -0,0 +1,225 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../guardian_login_controller.dart'; +import 'login_error_banner.dart'; +import 'login_form_parts.dart'; + +/// Passwordless guardian login: e-mail step, then the mailed six-digit code. +/// A tapped mail link completes the second step without typing (handled by +/// the login screen). +class GuardianLoginCard extends StatefulWidget { + final GuardianLoginController controller; + final VoidCallback onSuccess; + + const GuardianLoginCard({ + required this.controller, + required this.onSuccess, + super.key, + }); + + @override + State createState() => _GuardianLoginCardState(); +} + +class _GuardianLoginCardState extends State { + final _emailFormKey = GlobalKey(); + final _codeFormKey = GlobalKey(); + final _emailController = TextEditingController(); + final _codeController = TextEditingController(); + Timer? _resendTicker; + + GuardianLoginController get _controller => widget.controller; + + @override + void initState() { + super.initState(); + _controller.addListener(_onControllerChange); + _syncResendTicker(); + } + + @override + void dispose() { + _controller.removeListener(_onControllerChange); + _resendTicker?.cancel(); + _emailController.dispose(); + _codeController.dispose(); + super.dispose(); + } + + void _onControllerChange() { + if (!mounted) return; + _syncResendTicker(); + setState(() {}); + } + + // Rebuilds once per second while the resend cooldown runs so the countdown + // stays current. + void _syncResendTicker() { + final waiting = + _controller.step == GuardianLoginStep.enterCode && + !_controller.canResend(); + if (waiting && _resendTicker == null) { + _resendTicker = Timer.periodic(const Duration(seconds: 1), (_) { + if (!mounted) return; + setState(() {}); + if (_controller.canResend()) { + _resendTicker?.cancel(); + _resendTicker = null; + } + }); + } else if (!waiting) { + _resendTicker?.cancel(); + _resendTicker = null; + } + } + + String? _validateEmail(String? value) { + final email = (value ?? '').trim(); + if (email.isEmpty) return 'Eingabe erforderlich'; + if (!RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$').hasMatch(email)) { + return 'Bitte eine gültige E-Mail-Adresse eingeben'; + } + return null; + } + + String? _validateCode(String? value) { + final length = _controller.pending!.codeLength; + final code = GuardianLoginController.normalizeCode(value ?? ''); + return code.length == length + ? null + : 'Bitte den $length-stelligen Code eingeben'; + } + + Future _requestCode() async { + if (_controller.loading) return; + if (!(_emailFormKey.currentState?.validate() ?? false)) return; + final signedIn = await _controller.requestCode(_emailController.text); + if (signedIn && mounted) widget.onSuccess(); + } + + Future _submitCode() async { + if (_controller.loading) return; + if (!(_codeFormKey.currentState?.validate() ?? false)) return; + final signedIn = await _controller.submitCode(_codeController.text); + if (signedIn && mounted) widget.onSuccess(); + } + + @override + Widget build(BuildContext context) => switch (_controller.step) { + GuardianLoginStep.enterEmail => _buildEmailStep(context), + GuardianLoginStep.enterCode => _buildCodeStep(context), + }; + + Widget _buildEmailStep(BuildContext context) { + final theme = Theme.of(context); + return Form( + key: _emailFormKey, + child: LoginCardFrame( + title: 'Anmeldung für Eltern', + hint: + 'Gib die E-Mail-Adresse ein, die bei der Schule hinterlegt ist. ' + 'Du erhältst einen Anmeldecode per E-Mail.', + children: [ + TextFormField( + key: const Key('guardian-email-field'), + controller: _emailController, + enabled: !_controller.loading, + validator: _validateEmail, + autocorrect: false, + keyboardType: TextInputType.emailAddress, + autofillHints: const [AutofillHints.email], + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _requestCode(), + decoration: loginInputDecoration( + theme, + 'E-Mail-Adresse', + Icons.alternate_email, + ), + ), + LoginErrorBanner( + message: _controller.errorMessage, + details: _controller.errorDetails, + ), + const SizedBox(height: 20), + LoginSubmitButton( + key: const Key('guardian-request-button'), + label: 'Code anfordern', + loading: _controller.loading, + onPressed: _requestCode, + ), + ], + ), + ); + } + + Widget _buildCodeStep(BuildContext context) { + final theme = Theme.of(context); + final pending = _controller.pending!; + final remaining = pending.resendAvailableAt.difference(DateTime.now()); + return Form( + key: _codeFormKey, + child: LoginCardFrame( + title: 'Code eingeben', + hint: + 'Wir haben eine E-Mail an ${pending.email} gesendet. Gib den Code ' + 'ein oder tippe auf den Link in der E-Mail.', + children: [ + TextFormField( + key: const Key('guardian-code-field'), + controller: _codeController, + enabled: !_controller.loading, + validator: _validateCode, + autofocus: true, + keyboardType: TextInputType.number, + autofillHints: const [AutofillHints.oneTimeCode], + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(pending.codeLength), + ], + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _submitCode(), + decoration: loginInputDecoration( + theme, + 'Anmeldecode', + Icons.pin_outlined, + ), + ), + LoginErrorBanner( + message: _controller.errorMessage, + details: _controller.errorDetails, + ), + const SizedBox(height: 20), + LoginSubmitButton( + key: const Key('guardian-verify-button'), + label: 'Anmelden', + loading: _controller.loading, + onPressed: _submitCode, + ), + const SizedBox(height: 8), + Row( + children: [ + TextButton( + onPressed: _controller.loading ? null : _controller.changeEmail, + child: const Text('E-Mail ändern'), + ), + const Spacer(), + TextButton( + onPressed: _controller.loading || !_controller.canResend() + ? null + : _controller.resend, + child: Text( + _controller.canResend() + ? 'Erneut senden' + : 'Erneut senden (${remaining.inSeconds + 1} s)', + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/view/login/widgets/login_audience_card.dart b/lib/view/login/widgets/login_audience_card.dart new file mode 100644 index 0000000..0e83ae6 --- /dev/null +++ b/lib/view/login/widgets/login_audience_card.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; + +import 'login_form_parts.dart'; + +enum LoginAudience { school, guardian } + +/// First login step: who is signing in. School accounts and guardians use +/// entirely different forms, so the choice comes before any input. +class LoginAudienceCard extends StatelessWidget { + final ValueChanged onSelected; + + const LoginAudienceCard({required this.onSelected, super.key}); + + @override + Widget build(BuildContext context) => LoginCardFrame( + title: 'Anmelden', + hint: 'Bite wähle deine Anmeldemethode', + children: [ + _AudienceButton( + key: const Key('login-audience-school'), + icon: Icons.school_outlined, + label: 'Login für Schülerschaft & Lehrkräfte', + onPressed: () => onSelected(LoginAudience.school), + ), + const SizedBox(height: 12), + _AudienceButton( + key: const Key('login-audience-guardian'), + icon: Icons.family_restroom_outlined, + label: 'Login für Eltern', + onPressed: () => onSelected(LoginAudience.guardian), + ), + ], + ); +} + +class _AudienceButton extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback onPressed; + + const _AudienceButton({ + required this.icon, + required this.label, + required this.onPressed, + super.key, + }); + + @override + Widget build(BuildContext context) => SizedBox( + height: 64, + child: FilledButton.tonalIcon( + onPressed: onPressed, + icon: Icon(icon, size: 26), + label: Text(label), + style: FilledButton.styleFrom( + alignment: Alignment.centerLeft, + padding: const EdgeInsets.symmetric(horizontal: 20), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + ), + ), + ); +} 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..7299640 --- /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 both 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 both 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), + ), + ); +} diff --git a/lib/view/pages/absence_report/absence_form_policy.dart b/lib/view/pages/absence_report/absence_form_policy.dart new file mode 100644 index 0000000..715ebfd --- /dev/null +++ b/lib/view/pages/absence_report/absence_form_policy.dart @@ -0,0 +1,28 @@ +import '../../../api/marianumconnect/queries/get_capabilities/guardian_child.dart'; +import '../../../session/session.dart'; +import '../../../state/app/modules/children/child_selection_cubit.dart'; + +/// Who an absence report is filed for, and what the form lets the user edit. +class AbsenceFormPolicy { + /// The child the report is for; null when users report for themselves. + final GuardianChild? child; + + const AbsenceFormPolicy._(this.child); + + /// Guardians report for a linked child whose identity the server knows, + /// so name and class are fixed. + bool get identityEditable => child == null; + + /// Null when the session cannot file a report (guardian without children). + static AbsenceFormPolicy? resolve({ + required Session? session, + required List children, + required String? selectedChildId, + }) => switch (session) { + GuardianSession() => switch (effectiveChild(children, selectedChildId)) { + null => null, + final child => AbsenceFormPolicy._(child), + }, + _ => const AbsenceFormPolicy._(null), + }; +} diff --git a/lib/view/pages/absence_report/absence_report_view.dart b/lib/view/pages/absence_report/absence_report_view.dart index cf0aa6c..a027c37 100644 --- a/lib/view/pages/absence_report/absence_report_view.dart +++ b/lib/view/pages/absence_report/absence_report_view.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../api/errors/error_mapper.dart'; import '../../../api/marianumconnect/queries/absence/absence_classes.dart'; @@ -6,24 +7,55 @@ import '../../../api/marianumconnect/queries/absence/absence_prefill.dart'; import '../../../api/marianumconnect/queries/absence/absence_prefill_response.dart'; import '../../../api/marianumconnect/queries/absence/absence_submit.dart'; import '../../../extensions/date_time.dart'; +import '../../../session/session_manager.dart'; +import '../../../state/app/modules/capabilities/bloc/capabilities_cubit.dart'; +import '../../../state/app/modules/children/child_selection_cubit.dart'; import '../../../widget/app_progress_indicator.dart'; import '../../../widget/async_action_button.dart'; +import '../../../widget/child_switcher.dart'; import '../../../widget/demo_restricted.dart'; import '../../../widget/focus_behaviour.dart'; import '../../../widget/placeholder_view.dart'; +import 'absence_form_policy.dart'; /// Mobile mirror of the public absence-report form: submit-only (no history — /// that lives on the web). Identity/class/phone are prefilled from the backend /// but stay editable; the class list matches the submit validation source. +/// Guardians report for the selected child, whose identity is fixed. /// User-facing texts mirror the public web form (`AbsencePublicForm.svelte`). -class AbsenceReportView extends StatefulWidget { +class AbsenceReportView extends StatelessWidget { const AbsenceReportView({super.key}); @override - State createState() => _AbsenceReportViewState(); + Widget build(BuildContext context) { + final policy = AbsenceFormPolicy.resolve( + session: SessionManager().current, + children: context.watch().state.children, + selectedChildId: context.watch().state, + ); + return Scaffold( + appBar: AppBar( + title: const Text('Abwesenheitsmeldung'), + actions: const [ChildSwitcher()], + ), + body: policy == null + ? const NoChildrenPlaceholder() + // Re-created per child so no input leaks into another child's report. + : _AbsenceForm(key: ValueKey(policy.child?.id), policy: policy), + ); + } } -class _AbsenceReportViewState extends State { +class _AbsenceForm extends StatefulWidget { + final AbsenceFormPolicy policy; + + const _AbsenceForm({required this.policy, super.key}); + + @override + State<_AbsenceForm> createState() => _AbsenceFormState(); +} + +class _AbsenceFormState extends State<_AbsenceForm> { static const String _required = 'Dieses Feld ist erforderlich.'; final TextEditingController _firstName = TextEditingController(); @@ -67,7 +99,17 @@ class _AbsenceReportViewState extends State { if (_submitted) setState(() {}); } + String? get _childId => widget.policy.child?.id; + Future _load() async { + if (!widget.policy.identityEditable) { + // The child's identity is the whole point of the form, so a failed + // prefill is an error here, not a degraded start. + final prefill = await AbsencePrefill().run(childId: _childId); + _classes = [prefill.className]; + _applyPrefill(prefill, _classes); + return; + } // Both GETs are independent — fire them together. Prefill is best-effort // (mapped to null on failure), so a classes error still propagates while a // prefill failure never surfaces as an unhandled async error. @@ -148,6 +190,7 @@ class _AbsenceReportViewState extends State { absentUntil: _absentUntil, phone: _phone.text.trim(), note: _note.text.trim(), + childId: _childId, ); if (!mounted) return; // Replace the whole form with a terminal success screen. There is @@ -157,10 +200,8 @@ class _AbsenceReportViewState extends State { } @override - Widget build(BuildContext context) => Scaffold( - appBar: AppBar(title: const Text('Abwesenheitsmeldung')), - body: _done ? const _SubmittedView() : _buildBody(context), - ); + Widget build(BuildContext context) => + _done ? const _SubmittedView() : _buildBody(context); Widget _buildBody(BuildContext context) => FutureBuilder( future: _init, @@ -205,6 +246,7 @@ class _AbsenceReportViewState extends State { const SizedBox(height: 20), TextField( controller: _firstName, + readOnly: !widget.policy.identityEditable, textCapitalization: TextCapitalization.words, decoration: _decoration( 'Vorname', @@ -215,6 +257,7 @@ class _AbsenceReportViewState extends State { const SizedBox(height: 16), TextField( controller: _lastName, + readOnly: !widget.policy.identityEditable, textCapitalization: TextCapitalization.words, decoration: _decoration( 'Nachname', @@ -234,7 +277,9 @@ class _AbsenceReportViewState extends State { items: _classes .map((c) => DropdownMenuItem(value: c, child: Text(c))) .toList(), - onChanged: (value) => setState(() => _selectedClass = value), + onChanged: widget.policy.identityEditable + ? (value) => setState(() => _selectedClass = value) + : null, ), const SizedBox(height: 16), _DateField( 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/marianum_dates/widgets/event_list_tile.dart b/lib/view/pages/marianum_dates/widgets/event_list_tile.dart index ed8e483..f9f9c9a 100644 --- a/lib/view/pages/marianum_dates/widgets/event_list_tile.dart +++ b/lib/view/pages/marianum_dates/widgets/event_list_tile.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../state/app/modules/marianum_dates/bloc/marianum_dates_state.dart'; +import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; import '../../timetable/custom_events/custom_event_edit_dialog.dart'; import '../data/event_formatter.dart'; import 'event_details_sheet.dart'; @@ -89,24 +91,31 @@ class MarianumDateRow extends StatelessWidget { color: theme.colorScheme.onSurfaceVariant, ), ), - const SizedBox(width: 4), - IconButton( - icon: _CalendarPlusIcon( - color: theme.colorScheme.onSurfaceVariant, - ), - tooltip: 'In Stundenplan übernehmen', - onPressed: () => showDialog( - context: context, - builder: (_) => CustomEventEditDialog( - initialTitle: event.title, - initialDescription: event.description, - initialStart: event.start, - initialEnd: event.end, - initialAllDay: event.isAllDay, + // Custom events are private to the own plan; a guardian's plan + // belongs to the child. + if (context + .watch() + .subject + .supportsCustomEvents) ...[ + const SizedBox(width: 4), + IconButton( + icon: _CalendarPlusIcon( + color: theme.colorScheme.onSurfaceVariant, + ), + tooltip: 'In Stundenplan übernehmen', + onPressed: () => showDialog( + context: context, + builder: (_) => CustomEventEditDialog( + initialTitle: event.title, + initialDescription: event.description, + initialStart: event.start, + initialEnd: event.end, + initialAllDay: event.isAllDay, + ), + barrierDismissible: false, ), - barrierDismissible: false, ), - ), + ], ], ), ), diff --git a/lib/view/pages/settings/sections/account_section.dart b/lib/view/pages/settings/sections/account_section.dart index 797bc93..3f92dce 100644 --- a/lib/view/pages/settings/sections/account_section.dart +++ b/lib/view/pages/settings/sections/account_section.dart @@ -4,15 +4,19 @@ 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.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/capabilities/bloc/capabilities_cubit.dart'; import '../../../../widget/app_progress_indicator.dart'; import '../../../../widget/async_action_button.dart'; import '../../../../widget/avatar_actions_sheet.dart'; +import '../../../../widget/centered_leading.dart'; +import '../../../../widget/child_switcher.dart'; import '../../../../widget/confirm_dialog.dart'; import '../../../../widget/demo_restricted.dart'; import '../../../../widget/user_avatar.dart'; @@ -21,14 +25,53 @@ import '../../../../widget/user_avatar.dart'; // every Settings rebuild doesn't re-issue the OCS request. String? _cachedDisplayName; -class AccountSection extends StatefulWidget { +class AccountSection extends StatelessWidget { const AccountSection({super.key}); @override - State createState() => _AccountSectionState(); + Widget build(BuildContext context) => switch (SessionManager().current) { + GuardianSession(:final email) => _GuardianAccount(email: email), + _ => const _SchoolAccount(), + }; } -class _AccountSectionState extends State { +/// Guardians have no Nextcloud profile: show the e-mail and linked children. +class _GuardianAccount extends StatelessWidget { + final String email; + + const _GuardianAccount({required this.email}); + + @override + Widget build(BuildContext context) { + final children = context.watch().state.children; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ListTile( + contentPadding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + leading: const CenteredLeading(Icon(Icons.family_restroom_outlined)), + title: const Text('Elternkonto'), + subtitle: Text(email), + trailing: TextButton.icon( + icon: const Icon(Icons.logout_outlined, size: 18), + label: const Text('Abmelden'), + onPressed: () => _confirmLogout(context), + ), + ), + for (final child in children) ChildTile(child: child), + ], + ); + } +} + +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; @@ -42,9 +85,7 @@ class _AccountSectionState extends State { Future _loadDisplayName() async { try { final info = await GetUserInfo().run(); - _cachedDisplayName = info.displayName.isEmpty - ? null - : info.displayName; + _cachedDisplayName = info.displayName.isEmpty ? null : info.displayName; if (!mounted) return; setState(() => _displayName = _cachedDisplayName); } catch (_) { @@ -84,13 +125,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); @@ -109,8 +154,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, @@ -164,7 +211,7 @@ class _AccountSectionState extends State { TextButton.icon( icon: const Icon(Icons.logout_outlined, size: 18), label: const Text('Abmelden'), - onPressed: () => _showLogoutDialog(context), + onPressed: () => _confirmLogout(context), ), ], ), @@ -172,13 +219,11 @@ 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'), - subtitle: const Text( - 'Bei Anmeldeproblemen in Talk oder Dateien', - ), + subtitle: const Text('Bei Anmeldeproblemen in Talk oder Dateien'), closeOnSuccess: false, onPressed: _reconnectNextcloud, ), @@ -197,35 +242,29 @@ 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 removeData (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, - ), - ); - if (confirmed != true || !context.mounted) return; - context.read().setStatus(AccountStatus.loggedOut); - } +Future _confirmLogout(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, + ), + ); + if (confirmed != true || !context.mounted) return; + 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(); - _cachedDisplayName = null; - } +Future _performLogout() async { + await SessionLifecycle.signOut(); + _cachedDisplayName = null; } class _AvatarEditBadge extends StatelessWidget { @@ -253,11 +292,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/view/pages/settings/sections/notifications_section.dart b/lib/view/pages/settings/sections/notifications_section.dart new file mode 100644 index 0000000..cbe1ca0 --- /dev/null +++ b/lib/view/pages/settings/sections/notifications_section.dart @@ -0,0 +1,205 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../push/push_registration.dart'; +import '../../../../push/push_status.dart'; +import '../../../../session/session_manager.dart'; +import '../../../../state/app/modules/capabilities/bloc/capabilities_cubit.dart'; +import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; +import '../../../../widget/centered_leading.dart'; +import '../widgets/push_status_sheet.dart'; +import '../widgets/settings_checkbox_tile.dart'; + +class NotificationsSection extends StatelessWidget { + const NotificationsSection({super.key}); + + @override + Widget build(BuildContext context) { + final settings = context.watch(); + return _PushSettings( + settings: settings, + capabilities: context.read(), + enabled: settings.val().notificationSettings.enabled, + devMode: settings.val().devToolsEnabled, + // The status checklist describes the Nextcloud chain (app passwords, + // keypair, general/talk registrations); a direct registration has none + // of these links. + showChainStatus: SessionManager().hasNextcloud, + ); + } +} + +/// The push area: the enable switch carries an at-a-glance health icon (green +/// check / red X) right before the checkbox, and the detailed status checklist +/// is hidden — it only surfaces when the chain is broken or the developer mode +/// is on. Owns the [PushStatusReport] loading so the inline icon and the detail +/// entry share one source of truth. +class _PushSettings extends StatefulWidget { + final SettingsCubit settings; + final CapabilitiesCubit capabilities; + final bool enabled; + final bool devMode; + final bool showChainStatus; + + const _PushSettings({ + required this.settings, + required this.capabilities, + required this.enabled, + required this.devMode, + required this.showChainStatus, + }); + + @override + State<_PushSettings> createState() => _PushSettingsState(); +} + +class _PushSettingsState extends State<_PushSettings> + with WidgetsBindingObserver { + PushStatusReport? _report; + + /// True while a (de)registration triggered by the switch is in flight. The + /// report collected in that window still reflects the pre-registration state, + /// so the status is shown as "loading" instead of briefly flashing red. + bool _busy = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + unawaited(_load()); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _PushSettings oldWidget) { + super.didUpdateWidget(oldWidget); + // Toggling the setting changes several links at once — re-collect. + if (oldWidget.enabled != widget.enabled) unawaited(_load()); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + // The OS permission can change while the app is backgrounded. + if (state == AppLifecycleState.resumed) unawaited(_load()); + } + + Future _load() async { + if (!widget.showChainStatus) return; + final caps = widget.capabilities.state; + final report = await collectPushStatus( + settingEnabled: widget.settings.val().notificationSettings.enabled, + capabilityPush: caps.pushNotifications, + capabilitiesLoaded: caps.loaded, + ); + if (!mounted) return; + setState(() => _report = report); + } + + void _onToggle(bool enabled) { + widget.settings.val(write: true).notificationSettings.enabled = 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 + // and ensures the OS permission. + if (!enabled) return; + setState(() => _busy = true); + 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 { + messenger.showSnackBar( + const SnackBar( + content: Text( + 'Die Benachrichtigungsberechtigung wurde in den ' + 'Systemeinstellungen deaktiviert. Bitte aktiviere sie dort, um ' + 'Push-Benachrichtigungen zu erhalten.', + ), + ), + ); + } + } finally { + if (mounted) await _load(); + if (mounted) setState(() => _busy = false); + } + }()); + } + + @override + Widget build(BuildContext context) { + final report = _report; + final broken = + widget.enabled && !_busy && report != null && !report.chainHealthy; + return Column( + children: [ + SettingsCheckboxTile( + icon: Icons.notifications_active_outlined, + title: 'Push-Benachrichtigungen', + subtitle: widget.showChainStatus + ? 'Benachrichtigungen bei neuen Talk-Nachrichten erhalten' + : 'Benachrichtigungen der Schule erhalten', + value: widget.enabled, + beforeCheckbox: _inlineStatusIcon(report), + onChanged: _onToggle, + ), + // Detail entry only when there is a problem to fix or for developers. + if (widget.showChainStatus && (broken || widget.devMode)) + _detailTile(error: broken), + ], + ); + } + + /// Health icon shown before the checkbox — a spinner while a registration is + /// in flight, otherwise the green/red verdict once the report has loaded. + Widget? _inlineStatusIcon(PushStatusReport? report) { + if (_busy) { + return const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ); + } + if (!widget.enabled || report == null) return null; + final healthy = report.chainHealthy; + return Icon( + healthy ? Icons.check_circle : Icons.cancel, + color: healthy ? Colors.green : Theme.of(context).colorScheme.error, + semanticLabel: healthy ? 'Push in Ordnung' : 'Push funktioniert nicht', + ); + } + + /// The full status checklist entry — same list-tile footprint whether broken + /// or not; a problem is signalled only through the error-colored icon/text. + Widget _detailTile({required bool error}) { + final color = error ? Theme.of(context).colorScheme.error : null; + final textStyle = color == null ? null : TextStyle(color: color); + return ListTile( + leading: CenteredLeading( + Icon(Icons.monitor_heart_outlined, color: color), + ), + title: Text('Push-Status', style: textStyle), + subtitle: Text( + error + ? 'Ein Schritt in der Zustellkette ist unterbrochen' + : 'Registrierung und Zustellung im Detail', + style: textStyle, + ), + trailing: Icon(Icons.arrow_right, color: color), + // The sheet can re-register; re-collect on close so the dot reflects it. + onTap: () async { + await showPushStatusSheet(context); + if (mounted) await _load(); + }, + ); + } +} diff --git a/lib/view/pages/settings/sections/talk_section.dart b/lib/view/pages/settings/sections/talk_section.dart index 0a1a79b..4570ba1 100644 --- a/lib/view/pages/settings/sections/talk_section.dart +++ b/lib/view/pages/settings/sections/talk_section.dart @@ -1,15 +1,8 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../../../push/push_registration.dart'; -import '../../../../push/push_status.dart'; import '../../../../routing/app_routes.dart'; -import '../../../../state/app/modules/capabilities/bloc/capabilities_cubit.dart'; import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; -import '../../../../widget/centered_leading.dart'; -import '../widgets/push_status_sheet.dart'; import '../widgets/settings_checkbox_tile.dart'; class TalkSection extends StatelessWidget { @@ -42,180 +35,7 @@ class TalkSection extends StatelessWidget { trailing: const Icon(Icons.arrow_right), onTap: () => AppRoutes.openChatBackgroundSettings(context), ), - _PushSettings( - settings: settings, - capabilities: context.read(), - enabled: settings.val().notificationSettings.enabled, - devMode: settings.val().devToolsEnabled, - ), ], ); } } - -/// The push area: the enable switch carries an at-a-glance health icon (green -/// check / red X) right before the checkbox, and the detailed status checklist -/// is hidden — it only surfaces when the chain is broken or the developer mode -/// is on. Owns the [PushStatusReport] loading so the inline icon and the detail -/// entry share one source of truth. -class _PushSettings extends StatefulWidget { - final SettingsCubit settings; - final CapabilitiesCubit capabilities; - final bool enabled; - final bool devMode; - - const _PushSettings({ - required this.settings, - required this.capabilities, - required this.enabled, - required this.devMode, - }); - - @override - State<_PushSettings> createState() => _PushSettingsState(); -} - -class _PushSettingsState extends State<_PushSettings> - with WidgetsBindingObserver { - PushStatusReport? _report; - - /// True while a (de)registration triggered by the switch is in flight. The - /// report collected in that window still reflects the pre-registration state, - /// so the status is shown as "loading" instead of briefly flashing red. - bool _busy = false; - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addObserver(this); - unawaited(_load()); - } - - @override - void dispose() { - WidgetsBinding.instance.removeObserver(this); - super.dispose(); - } - - @override - void didUpdateWidget(covariant _PushSettings oldWidget) { - super.didUpdateWidget(oldWidget); - // Toggling the setting changes several links at once — re-collect. - if (oldWidget.enabled != widget.enabled) unawaited(_load()); - } - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - // The OS permission can change while the app is backgrounded. - if (state == AppLifecycleState.resumed) unawaited(_load()); - } - - Future _load() async { - final caps = widget.capabilities.state; - final report = await collectPushStatus( - settingEnabled: widget.settings.val().notificationSettings.enabled, - capabilityPush: caps.pushNotifications, - capabilitiesLoaded: caps.loaded, - ); - if (!mounted) return; - setState(() => _report = report); - } - - void _onToggle(bool enabled) { - widget.settings.val(write: true).notificationSettings.enabled = 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 - // and ensures the OS permission. - if (!enabled) return; - setState(() => _busy = true); - 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 { - messenger.showSnackBar( - const SnackBar( - content: Text( - 'Die Benachrichtigungsberechtigung wurde in den ' - 'Systemeinstellungen deaktiviert. Bitte aktiviere sie dort, um ' - 'Push-Benachrichtigungen zu erhalten.', - ), - ), - ); - } - } finally { - if (mounted) await _load(); - if (mounted) setState(() => _busy = false); - } - }()); - } - - @override - Widget build(BuildContext context) { - final report = _report; - final broken = - widget.enabled && !_busy && report != null && !report.chainHealthy; - return Column( - children: [ - SettingsCheckboxTile( - icon: Icons.notifications_active_outlined, - title: 'Push-Benachrichtigungen', - subtitle: 'Benachrichtigungen bei neuen Talk-Nachrichten erhalten', - value: widget.enabled, - beforeCheckbox: _inlineStatusIcon(report), - onChanged: _onToggle, - ), - // Detail entry only when there is a problem to fix or for developers. - if (broken || widget.devMode) _detailTile(error: broken), - ], - ); - } - - /// Health icon shown before the checkbox — a spinner while a registration is - /// in flight, otherwise the green/red verdict once the report has loaded. - Widget? _inlineStatusIcon(PushStatusReport? report) { - if (_busy) { - return const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ); - } - if (!widget.enabled || report == null) return null; - final healthy = report.chainHealthy; - return Icon( - healthy ? Icons.check_circle : Icons.cancel, - color: healthy ? Colors.green : Theme.of(context).colorScheme.error, - semanticLabel: healthy ? 'Push in Ordnung' : 'Push funktioniert nicht', - ); - } - - /// The full status checklist entry — same list-tile footprint whether broken - /// or not; a problem is signalled only through the error-colored icon/text. - Widget _detailTile({required bool error}) { - final color = error ? Theme.of(context).colorScheme.error : null; - final textStyle = color == null ? null : TextStyle(color: color); - return ListTile( - leading: CenteredLeading( - Icon(Icons.monitor_heart_outlined, color: color), - ), - title: Text('Push-Status', style: textStyle), - subtitle: Text( - error - ? 'Ein Schritt in der Zustellkette ist unterbrochen' - : 'Registrierung und Zustellung im Detail', - style: textStyle, - ), - trailing: Icon(Icons.arrow_right, color: color), - // The sheet can re-register; re-collect on close so the dot reflects it. - onTap: () async { - await showPushStatusSheet(context); - if (mounted) await _load(); - }, - ); - } -} diff --git a/lib/view/pages/settings/settings.dart b/lib/view/pages/settings/settings.dart index 6c6b970..c0c775d 100644 --- a/lib/view/pages/settings/settings.dart +++ b/lib/view/pages/settings/settings.dart @@ -1,35 +1,49 @@ import 'package:flutter/material.dart'; +import '../../../access/access_requirement.dart'; +import '../../../session/session_manager.dart'; import 'sections/about_section.dart'; import 'sections/account_section.dart'; import 'sections/appearance_section.dart'; import 'sections/files_section.dart'; import 'sections/modules_section.dart'; +import 'sections/notifications_section.dart'; import 'sections/talk_section.dart'; import 'sections/timetable_section.dart'; class Settings extends StatelessWidget { const Settings({super.key}); + /// Sections in display order with the backend identities they need; + /// sections the session cannot use are left out. + static const List<(Widget, Set)> _sections = [ + (AccountSection(), {}), + (AppearanceSection(), {}), + (ModulesSection(), {}), + (TimetableSection(), {}), + (NotificationsSection(), {}), + (TalkSection(), {AccessRequirement.nextcloud}), + (FilesSection(), {AccessRequirement.nextcloud}), + (AboutSection(), {}), + ]; + @override - Widget build(BuildContext context) => Scaffold( - appBar: AppBar(title: const Text('Einstellungen')), - body: ListView( - children: const [ - AccountSection(), - Divider(), - AppearanceSection(), - Divider(), - ModulesSection(), - Divider(), - TimetableSection(), - Divider(), - TalkSection(), - Divider(), - FilesSection(), - Divider(), - AboutSection(), - ], - ), - ); + Widget build(BuildContext context) { + final session = SessionManager().current; + final visible = [ + for (final (section, requirements) in _sections) + if (requirements.areMetBy(session)) section, + ]; + return Scaffold( + appBar: AppBar(title: const Text('Einstellungen')), + body: ListView( + children: [ + for (final (i, section) in visible.indexed) ...[ + if (i > 0) const Divider(), + section, + ], + ], + ), + ); + } } 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 36184cc..e409a45 100644 --- a/lib/view/pages/talk/widgets/chat_tile.dart +++ b/lib/view/pages/talk/widgets/chat_tile.dart @@ -8,9 +8,9 @@ import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dar import '../../../../api/marianumcloud/talk/room/get_room_response.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'; @@ -51,13 +51,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/view/pages/talk/widgets/user_search_tile.dart b/lib/view/pages/talk/widgets/user_search_tile.dart index 0943b8a..40f4b77 100644 --- a/lib/view/pages/talk/widgets/user_search_tile.dart +++ b/lib/view/pages/talk/widgets/user_search_tile.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../../../../access/user_role.dart'; import '../../../../api/marianumconnect/queries/user_search/user_search_response.dart'; import '../../../../widget/user_avatar.dart'; @@ -18,14 +19,14 @@ class UserSearchTile extends StatelessWidget { leading: UserAvatar(id: user.username, isGroup: false), title: Text('${user.firstName} ${user.lastName}'), subtitle: Text(_subtitle), - trailing: RoleBadge(userType: user.userType), + trailing: RoleBadge(role: UserRole.parse(user.userType)), onTap: onTap, ); } String get _subtitle { final className = user.className; - if (user.userType == 'STUDENT' && + if (UserRole.parse(user.userType) == UserRole.student && className != null && className.isNotEmpty) { return '${user.username} · $className'; @@ -36,16 +37,17 @@ class UserSearchTile extends StatelessWidget { /// Compact colour-coded badge distinguishing teachers, students and staff. class RoleBadge extends StatelessWidget { - final String userType; + final UserRole role; - const RoleBadge({super.key, required this.userType}); + const RoleBadge({super.key, required this.role}); @override Widget build(BuildContext context) { - final (label, color) = switch (userType) { - 'TEACHER' => ('Lehrkraft', Colors.blue), - 'STUDENT' => ('Schüler:in', Colors.green), - _ => ('Personal', Colors.orange), + final (label, color) = switch (role) { + UserRole.teacher => ('Lehrkraft', Colors.blue), + UserRole.student => ('Schüler:in', Colors.green), + UserRole.parent => ('Elternteil', Colors.purple), + UserRole.staff || UserRole.unknown => ('Personal', Colors.orange), }; return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), diff --git a/lib/view/pages/timetable/timetable.dart b/lib/view/pages/timetable/timetable.dart index 46f4f74..206f110 100644 --- a/lib/view/pages/timetable/timetable.dart +++ b/lib/view/pages/timetable/timetable.dart @@ -6,11 +6,14 @@ import '../../../extensions/date_time.dart'; import '../../../routing/app_routes.dart'; import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; import '../../../state/app/modules/capabilities/bloc/capabilities_cubit.dart'; -import '../../../state/app/modules/foreign_timetable/bloc/foreign_timetable_bloc.dart'; import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; import '../../../state/app/modules/timetable/bloc/timetable_state.dart'; +import '../../../state/app/modules/timetable/policy/timetable_policy.dart'; +import '../../../state/app/modules/timetable/subject/timetable_subject.dart'; import '../../../utils/haptics.dart'; +import '../../../widget/app_progress_indicator.dart'; +import '../../../widget/child_switcher.dart'; import '../../../widget/demo_restricted.dart'; import 'custom_events/custom_event_edit_dialog.dart'; import 'details/appointment_details_dispatcher.dart'; @@ -26,8 +29,9 @@ class Timetable extends StatefulWidget { } class _TimetableState extends State { - final GlobalKey _calendarKey = + GlobalKey _calendarKey = GlobalKey(); + TimetableSubject? _calendarSubject; /// When non-null the view shows this element's plan inline instead of the /// user's own. Cleared (back to own plan) via the viewing banner. @@ -78,31 +82,39 @@ class _TimetableState extends State { @override Widget build(BuildContext context) { final selected = _selected; - if (selected == null) return _buildOwnPlan(context); + if (selected == null) { + final primary = context.watch().subject; + if (primary is NoTimetable) return const _NoTimetableView(); + return _buildPlan(context); + } // Scope the foreign bloc to the current selection so switching elements // (or back to the own plan) tears it down and builds a fresh one. - return BlocProvider( + return BlocProvider( key: ValueKey('${selected.type.name}-${selected.id}'), - create: (_) => ForeignTimetableBloc( - type: selected.type, - elementId: selected.id, - title: selected.label, - ), - // Builder gives us a context *below* the provider so the foreign bloc is - // resolvable inside _buildForeignPlan. + create: (_) => ScopedTimetableBloc(subject: ElementTimetable(selected)), + // Builder gives us a context *below* the provider so the scoped bloc is + // resolvable inside _buildPlan. child: Builder( - builder: (context) => _buildForeignPlan(context, selected), + builder: (context) => _buildPlan(context), ), ); } - Widget _buildOwnPlan(BuildContext context) { - final bloc = context.read(); - final loadableState = context.watch().state; - final innerState = loadableState.data; + Widget _buildPlan(BuildContext context) { + final bloc = context.read(); + final subject = bloc.subject; + // A new subject (child switch) must not inherit the displayed week of the + // previous calendar state. + if (subject != _calendarSubject) { + _calendarSubject = subject; + _calendarKey = GlobalKey(); + } + final innerState = context.watch().state.data; final atToday = innerState != null && _isOnInitialWeek(innerState); - final capabilities = context.watch(); - final canViewForeign = capabilities.canViewForeignTimetables; + final policy = TimetablePolicy.resolve( + subject: subject, + capabilities: context.watch().state, + ); return Scaffold( appBar: AppBar( // Der Kalender scrollt nicht (nur ziehen/reloaden), aber seine internen @@ -111,87 +123,36 @@ class _TimetableState extends State { notificationPredicate: (_) => false, title: const Text('Stunden & Vertretungsplan'), actions: [ + // Hides itself unless a guardian has more than one child. + const ChildSwitcher(), IconButton( icon: const Icon(Icons.home_outlined), tooltip: 'Zur aktuellen Woche', onPressed: atToday ? null : _jumpToToday, ), - PopupMenuButton<_CalendarAction>( - tooltip: 'Kalendereinträge', - icon: const Icon(Icons.edit_calendar_outlined), - onSelected: _onAction, - itemBuilder: (_) => const [ - PopupMenuItem( - value: _CalendarAction.addEvent, - child: ListTile( - title: Text('Kalendereintrag hinzufügen'), - leading: Icon(Icons.add), + if (policy.canManageCustomEvents) + PopupMenuButton<_CalendarAction>( + tooltip: 'Kalendereinträge', + icon: const Icon(Icons.edit_calendar_outlined), + onSelected: _onAction, + itemBuilder: (_) => const [ + PopupMenuItem( + value: _CalendarAction.addEvent, + child: ListTile( + title: Text('Kalendereintrag hinzufügen'), + leading: Icon(Icons.add), + ), ), - ), - PopupMenuItem( - value: _CalendarAction.viewEvents, - child: ListTile( - title: Text('Kalendereinträge anzeigen'), - leading: Icon(Icons.perm_contact_calendar_outlined), + PopupMenuItem( + value: _CalendarAction.viewEvents, + child: ListTile( + title: Text('Kalendereinträge anzeigen'), + leading: Icon(Icons.perm_contact_calendar_outlined), + ), ), - ), - ], - ), - if (canViewForeign) - IconButton( - icon: const Icon(Icons.person_search), - tooltip: 'Anderen Stundenplan öffnen', - onPressed: _openPicker, + ], ), - ], - ), - body: LoadableStateConsumer( - // Without this predicate the consumer treats the freshly-initialised - // empty TimetableState as "has content" and only shows the error bar - // on top — but the calendar view collapses to `SizedBox.shrink()` - // while the reference data is missing, leaving the user with a blank - // screen. Telling the consumer that "ready" means having reference - // data flips it into the proper error-screen path instead. - isReady: (state) => state.hasReferenceData, - child: (state, _) => TimetableCalendarView( - key: _calendarKey, - state: state, - onWeekChanged: bloc.changeWeek, - onAppointmentTap: (apt) => AppointmentDetailsDispatcher.show( - context, - state, - apt, - canEditSubjectColor: true, - ), - onCreateEvent: _onCreateEventAt, - customEvents: state.customEvents?.events ?? const [], - showClassInsteadOfTeacher: capabilities.isTeacher, - ), - ), - ); - } - - Widget _buildForeignPlan(BuildContext context, TimetableElementRef selected) { - final bloc = context.read(); - final loadableState = context.watch().state; - final innerState = loadableState.data; - final atToday = innerState != null && _isOnInitialWeek(innerState); - final canViewForeign = context - .watch() - .canViewForeignTimetables; - return Scaffold( - appBar: AppBar( - // Siehe _buildOwnPlan: den scroll-under-Farbwechsel unterdrücken, weil - // der Kalender nicht scrollt, aber ScrollNotifications feuert. - notificationPredicate: (_) => false, - title: const Text('Stunden & Vertretungsplan'), - actions: [ - IconButton( - icon: const Icon(Icons.home_outlined), - tooltip: 'Zur aktuellen Woche', - onPressed: atToday ? null : _jumpToToday, - ), - if (canViewForeign) + if (policy.canOpenForeign) IconButton( icon: const Icon(Icons.person_search), tooltip: 'Anderen Stundenplan öffnen', @@ -201,24 +162,33 @@ class _TimetableState extends State { ), body: Column( children: [ - _ViewingBanner(element: selected, onClose: _backToOwnPlan), + if (subject case ElementTimetable(:final element)) + _ViewingBanner(element: element, onClose: _backToOwnPlan), Expanded( - child: LoadableStateConsumer( - // Foreign plans never carry custom events, so unlike the own-plan - // view we must not require `customEvents` here. - isReady: (state) => - state.rooms != null && - state.subjects != null && - state.schoolHolidays != null, + child: LoadableStateConsumer( + // Without this predicate the consumer treats the freshly- + // initialised empty TimetableState as "has content" and only + // shows the error bar on top — but the calendar view collapses + // to `SizedBox.shrink()` while the reference data is missing, + // leaving the user with a blank screen. + isReady: (state) => state.isReady( + needsCustomEvents: subject.supportsCustomEvents, + ), child: (state, _) => TimetableCalendarView( key: _calendarKey, state: state, onWeekChanged: bloc.changeWeek, - onAppointmentTap: (apt) => - AppointmentDetailsDispatcher.show(context, state, apt), - customEvents: const [], - showClassInsteadOfTeacher: - selected.type == TimetableElementType.teacher, + onAppointmentTap: (apt) => AppointmentDetailsDispatcher.show( + context, + state, + apt, + canEditSubjectColor: policy.canEditSubjectColors, + ), + onCreateEvent: policy.canManageCustomEvents + ? _onCreateEventAt + : null, + customEvents: state.customEvents?.events ?? const [], + showClassInsteadOfTeacher: policy.showClassInsteadOfTeacher, ), ), ), @@ -233,6 +203,23 @@ class _TimetableState extends State { } } +/// Shown instead of a plan when the session has none, i.e. a guardian whose +/// children are not known (yet). +class _NoTimetableView extends StatelessWidget { + const _NoTimetableView(); + + @override + Widget build(BuildContext context) { + final capabilities = context.watch().state; + return Scaffold( + appBar: AppBar(title: const Text('Stunden & Vertretungsplan')), + body: capabilities.loaded + ? const NoChildrenPlaceholder() + : const Center(child: AppProgressIndicator.large()), + ); + } +} + /// Slim banner shown at the top of the timetable while a foreign element's plan /// is being viewed. Displays which element is shown, lets the user star it, and /// offers a one-tap return to the own plan. diff --git a/lib/widget/child_switcher.dart b/lib/widget/child_switcher.dart new file mode 100644 index 0000000..af9f809 --- /dev/null +++ b/lib/widget/child_switcher.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../api/marianumconnect/queries/get_capabilities/guardian_child.dart'; +import '../state/app/modules/capabilities/bloc/capabilities_cubit.dart'; +import '../state/app/modules/children/child_selection_cubit.dart'; +import '../utils/haptics.dart'; +import 'centered_leading.dart'; +import 'details_bottom_sheet.dart'; +import 'placeholder_view.dart'; + +/// AppBar action that shows the selected child and lets a guardian switch to +/// another one. Invisible unless there is a choice to make. +class ChildSwitcher extends StatelessWidget { + const ChildSwitcher({super.key}); + + @override + Widget build(BuildContext context) { + final children = context.watch().state.children; + if (children.length < 2) return const SizedBox.shrink(); + final selected = effectiveChild( + children, + context.watch().state, + )!; + return TextButton.icon( + icon: const Icon(Icons.face_outlined), + label: Text(selected.firstName), + onPressed: () => _pick(context, selected.id), + ); + } + + void _pick(BuildContext context, String selectedId) { + final selection = context.read(); + final children = context.read().state.children; + showDetailsBottomSheet( + context, + header: const ListTile(title: Text('Kind auswählen')), + children: (sheetContext) => [ + for (final child in children) + ChildTile( + child: child, + trailing: child.id == selectedId ? const Icon(Icons.check) : null, + onTap: () { + Haptics.selection(); + selection.select(child.id); + Navigator.pop(sheetContext); + }, + ), + ], + ); + } +} + +/// One linked child: name and class. +class ChildTile extends StatelessWidget { + final GuardianChild child; + final Widget? trailing; + final VoidCallback? onTap; + + const ChildTile({required this.child, this.trailing, this.onTap, super.key}); + + @override + Widget build(BuildContext context) => ListTile( + leading: const CenteredLeading(Icon(Icons.face_outlined)), + title: Text(child.displayName), + subtitle: child.className.isEmpty + ? null + : Text('Klasse ${child.className}'), + trailing: trailing, + onTap: onTap, + ); +} + +/// Shown by per-child modules while a guardian has no linked child. +class NoChildrenPlaceholder extends StatelessWidget { + const NoChildrenPlaceholder({super.key}); + + @override + Widget build(BuildContext context) => const PlaceholderView( + icon: Icons.family_restroom_outlined, + text: + 'Deinem Konto ist noch kein Kind zugeordnet. Bitte wende dich an ' + 'das Sekretariat.', + ); +} 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 3db7fb8..2c43366 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,7 @@ Future _fetchAvatarPayload(String url) async { () => http.get( Uri.parse(url), headers: { - ...AccountData().authHeaders(), + ...SessionManager().requireNextcloud().authHeaders, 'Accept': 'image/png,image/jpeg,image/webp,image/svg+xml', }, ), @@ -368,7 +368,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 9cac9a5..8eed48b 100644 --- a/lib/widget_data/widget_publisher.dart +++ b/lib/widget_data/widget_publisher.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import '../state/app/modules/timetable/bloc/timetable_state.dart'; +import '../state/app/modules/timetable/subject/timetable_subject.dart'; import '../storage/settings.dart'; import 'widget_data_mapper.dart'; import 'widget_sync.dart'; @@ -21,8 +22,9 @@ class WidgetPublisher { static Future publishFromBlocState( TimetableState state, { + required TimetableSubject subject, Settings? settings, - bool isTeacher = false, + bool showClassInsteadOfTeacher = false, }) async { try { final connectDouble = @@ -32,7 +34,8 @@ class WidgetPublisher { await Future.wait([ WidgetSync.setConnectDoubleLessons(connectDouble), WidgetSync.setThemeMode(_themeName(settings?.appTheme)), - WidgetSync.setIsTeacher(isTeacher), + WidgetSync.setShowClassInsteadOfTeacher(showClassInsteadOfTeacher), + WidgetSync.setSubject(subject), ]); final lessons = state.getAllKnownLessons(); final now = widgetNow(); @@ -45,7 +48,7 @@ class WidgetPublisher { timegrid: state.timegrid, customEvents: state.customEvents, connectDoubleLessons: connectDouble, - showClassInsteadOfTeacher: isTeacher, + showClassInsteadOfTeacher: showClassInsteadOfTeacher, ); final weekData = WidgetDataMapper.buildWeekData( now: now, @@ -56,7 +59,7 @@ class WidgetPublisher { timegrid: state.timegrid, customEvents: state.customEvents, connectDoubleLessons: connectDouble, - showClassInsteadOfTeacher: isTeacher, + showClassInsteadOfTeacher: showClassInsteadOfTeacher, ); await WidgetSync.writeDayData(dayData); await WidgetSync.writeWeekData(weekData); diff --git a/lib/widget_data/widget_sync.dart b/lib/widget_data/widget_sync.dart index 500fc36..efcff01 100644 --- a/lib/widget_data/widget_sync.dart +++ b/lib/widget_data/widget_sync.dart @@ -4,6 +4,7 @@ import 'dart:developer'; import 'package:home_widget/home_widget.dart'; +import '../state/app/modules/timetable/subject/timetable_subject.dart'; import 'widget_data.dart'; /// Bridge to the native widget host. All keys/names live here so the Kotlin @@ -31,15 +32,50 @@ class WidgetSync { static const String connectDoubleLessonsKey = 'widget_setting_connect_double_lessons_v1'; static const String themeModeKey = 'widget_setting_theme_mode_v1'; - // Mirrored from CapabilitiesCubit so the background isolate can render - // teacher plans (class instead of teacher name) without bloc storage. - static const String isTeacherKey = 'widget_setting_is_teacher_v1'; + // Mirrors the resolved TimetablePolicy flag so the background isolate + // renders tiles like the app does, without bloc storage. The key predates + // the policy (it used to mirror "is teacher") and keeps its name so + // existing installs stay consistent. + static const String showClassInsteadOfTeacherKey = + 'widget_setting_is_teacher_v1'; // Mirrored so the background isolate hits the same Marianum-Connect base // URL the in-app settings cubit currently has selected. - static const String marianumConnectBaseUrlKey = 'widget_setting_mc_base_url_v1'; + static const String marianumConnectBaseUrlKey = + 'widget_setting_mc_base_url_v1'; + + // Which plan the widget shows ('own' or 'child:'), so the background + // isolate fetches the same subject as the app. + static const String subjectKey = 'widget_setting_subject_v1'; static bool _initialised = false; + /// Only the primary subjects can back the widget; foreign plans cannot. + static String? encodeSubject(TimetableSubject subject) => switch (subject) { + OwnTimetable() => 'own', + ChildTimetable(:final childId) => 'child:$childId', + ElementTimetable() || NoTimetable() => null, + }; + + /// A missing value means an install from before guardian accounts, which + /// always showed the own plan. + static TimetableSubject? decodeSubject(String? value) { + if (value == null || value == 'own') return const OwnTimetable(); + if (value.startsWith('child:') && value.length > 'child:'.length) { + return ChildTimetable(value.substring('child:'.length)); + } + return null; + } + + static Future setSubject(TimetableSubject subject) async { + await ensureInitialized(); + await HomeWidget.saveWidgetData(subjectKey, encodeSubject(subject)); + } + + static Future getSubject() async { + await ensureInitialized(); + return decodeSubject(await HomeWidget.getWidgetData(subjectKey)); + } + static Future ensureInitialized() async { if (_initialised) return; await HomeWidget.setAppGroupId(iosAppGroupId); @@ -72,10 +108,11 @@ class WidgetSync { static Future getConnectDoubleLessons() => _getBool(connectDoubleLessonsKey, defaultValue: true); - static Future setIsTeacher(bool value) => _setBool(isTeacherKey, value); + static Future setShowClassInsteadOfTeacher(bool value) => + _setBool(showClassInsteadOfTeacherKey, value); - static Future getIsTeacher() => - _getBool(isTeacherKey, defaultValue: false); + static Future getShowClassInsteadOfTeacher() => + _getBool(showClassInsteadOfTeacherKey, defaultValue: false); static Future _setBool(String key, bool value) async { await ensureInitialized(); @@ -114,6 +151,7 @@ class WidgetSync { await HomeWidget.saveWidgetData(weekDataKey, null); await HomeWidget.saveWidgetData(fetchedAtKey, null); await HomeWidget.saveWidgetData(loggedInKey, false); + await HomeWidget.saveWidgetData(subjectKey, null); } static Future triggerUpdate() async { diff --git a/pubspec.yaml b/pubspec.yaml index 4fed116..1ccb61f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -102,6 +102,9 @@ dependencies: # Underlying parser for flutter_markdown_plus; imported directly to build the # restricted ExtensionSet used for chat-message Markdown. markdown: ^7.3.1 + # Android App Links / iOS Universal Links for the passwordless guardian + # login (mail link opens the app). + app_links: ^7.2.1 integration_test: sdk: flutter diff --git a/test/access/user_role_test.dart b/test/access/user_role_test.dart new file mode 100644 index 0000000..e7f20f6 --- /dev/null +++ b/test/access/user_role_test.dart @@ -0,0 +1,28 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/access/user_role.dart'; +import 'package:marianum_mobile/state/app/modules/capabilities/bloc/capabilities_state.dart'; + +void main() { + test('parses the server wire values', () { + expect(UserRole.parse('STUDENT'), UserRole.student); + expect(UserRole.parse('TEACHER'), UserRole.teacher); + expect(UserRole.parse('STAFF'), UserRole.staff); + expect(UserRole.parse('PARENT'), UserRole.parent); + }); + + test('missing or unknown values are unknown, never an error', () { + expect(UserRole.parse(null), UserRole.unknown); + expect(UserRole.parse('ALUMNUS'), UserRole.unknown); + }); + + test('hydrated capability states from older versions stay readable', () { + final state = CapabilitiesState.fromJson({ + 'viewForeignTimetables': true, + 'pushNotifications': true, + 'userType': 'TEACHER', + 'loaded': true, + }); + expect(state.role, UserRole.teacher); + expect(CapabilitiesState.fromJson({}).role, UserRole.unknown); + }); +} diff --git a/test/auth_link/guardian_login_link_test.dart b/test/auth_link/guardian_login_link_test.dart new file mode 100644 index 0000000..3ca3a55 --- /dev/null +++ b/test/auth_link/guardian_login_link_test.dart @@ -0,0 +1,107 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/auth_link/device_binding.dart'; +import 'package:marianum_mobile/auth_link/guardian_login_link.dart'; +import 'package:marianum_mobile/auth_link/pending_guardian_request.dart'; + +void main() { + final live = Uri.parse('https://connect.marianum-fulda.de'); + + group('GuardianLoginLink.parse', () { + test('accepts a link of the active server', () { + final link = GuardianLoginLink.parse( + Uri.parse( + 'https://connect.marianum-fulda.de/app/guardian-login?rid=r1<=t1', + ), + apiBase: live, + ); + expect(link?.requestId, 'r1'); + expect(link?.linkToken, 't1'); + }); + + test('rejects links of another server', () { + expect( + GuardianLoginLink.parse( + Uri.parse( + 'https://connect-beta.marianum-fulda.de/app/guardian-login?rid=r<=t', + ), + apiBase: live, + ), + isNull, + ); + }); + + test('respects a path prefix of a custom server', () { + final custom = Uri.parse('https://dev.example.org/connect/'); + expect( + GuardianLoginLink.parse( + Uri.parse( + 'https://dev.example.org/connect/app/guardian-login?rid=r<=t', + ), + apiBase: custom, + ), + isNotNull, + ); + }); + + test('rejects other paths, plain http and missing parameters', () { + for (final raw in [ + 'https://connect.marianum-fulda.de/app/other?rid=r<=t', + 'http://connect.marianum-fulda.de/app/guardian-login?rid=r<=t', + 'https://connect.marianum-fulda.de/app/guardian-login?rid=r', + 'https://connect.marianum-fulda.de/app/guardian-login?lt=t', + ]) { + expect( + GuardianLoginLink.parse(Uri.parse(raw), apiBase: live), + isNull, + reason: raw, + ); + } + }); + }); + + group('DeviceBinding', () { + test('challenge is the base64url SHA-256 of the secret, unpadded', () { + // RFC 7636 appendix B test vector. + expect( + DeviceBinding.challengeFor( + 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk', + ), + 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM', + ); + }); + + test('secrets are random and URL-safe', () { + final a = DeviceBinding.generateSecret(); + final b = DeviceBinding.generateSecret(); + expect(a, isNot(b)); + expect(a, matches(RegExp(r'^[A-Za-z0-9_-]{43}$'))); + }); + }); + + group('PendingGuardianRequest', () { + final request = PendingGuardianRequest( + requestId: 'r1', + email: 'e@x.de', + deviceSecret: 's', + expiresAt: DateTime.utc(2026, 9, 19, 12, 15), + resendAvailableAt: DateTime.utc(2026, 9, 19, 12, 1), + ); + + test('round-trips through JSON', () { + final copy = PendingGuardianRequest.fromJson(request.toJson())!; + expect(copy.requestId, 'r1'); + expect(copy.email, 'e@x.de'); + expect(copy.expiresAt, request.expiresAt); + expect(copy.codeLength, 6); + }); + + test('corrupt JSON reads as absent', () { + expect(PendingGuardianRequest.fromJson({'requestId': 1}), isNull); + }); + + test('expires at expiresAt', () { + expect(request.isExpired(DateTime.utc(2026, 9, 19, 12, 14)), isFalse); + expect(request.isExpired(DateTime.utc(2026, 9, 19, 12, 15)), isTrue); + }); + }); +} diff --git a/test/push/direct_push_registration_test.dart b/test/push/direct_push_registration_test.dart new file mode 100644 index 0000000..135c02a --- /dev/null +++ b/test/push/direct_push_registration_test.dart @@ -0,0 +1,46 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/push/direct_push_registration.dart'; +import 'package:marianum_mobile/push/push_secure_storage.dart'; + +class _MemoryStorage implements FlutterSecureStorageLike { + final Map values = {}; + + @override + Future read({required String key}) async => values[key]; + + @override + Future write({required String key, required String? value}) async { + if (value == null) { + values.remove(key); + } else { + values[key] = value; + } + } + + @override + Future delete({required String key}) async => values.remove(key); +} + +void main() { + test('device identifier is generated once and then reused', () async { + final storage = _MemoryStorage(); + final registration = DirectPushRegistration(storage: storage); + final first = await registration.deviceIdentifier(); + expect(first, matches(RegExp(r'^[0-9a-f]{32}$'))); + expect(await registration.deviceIdentifier(), first); + expect( + await DirectPushRegistration(storage: storage).deviceIdentifier(), + first, + ); + }); + + test('separate installs get different identifiers', () async { + final a = await DirectPushRegistration( + storage: _MemoryStorage(), + ).deviceIdentifier(); + final b = await DirectPushRegistration( + storage: _MemoryStorage(), + ).deviceIdentifier(); + expect(a, isNot(b)); + }); +} diff --git a/test/session/session_codec_test.dart b/test/session/session_codec_test.dart new file mode 100644 index 0000000..0bca214 --- /dev/null +++ b/test/session/session_codec_test.dart @@ -0,0 +1,134 @@ +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 guardian accounts', () { + 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 – guardian', () { + test('guardian kind with e-mail', () { + final session = decodeSession({ + 'session_kind': 'guardian', + 'guardian_email': 'eltern@example.org', + }); + expect(session, isA()); + expect(session!.nextcloud, isNull); + expect((session as GuardianSession).email, 'eltern@example.org'); + }); + + test('guardian kind without e-mail is invalid', () { + expect(decodeSession({'session_kind': 'guardian'}), isNull); + }); + + 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); + }); + + test('guardian clears credential fields', () { + final fields = encodeSessionFields( + const GuardianSession(email: 'e@x.de'), + ); + expect(fields['username'], isNull); + expect(fields['password'], isNull); + expect(fields.containsKey('username'), isTrue); + expect(decodeSession(fields), isA()); + }); + }); + + 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'); + }); + }); +} diff --git a/test/state/app_modules_order_test.dart b/test/state/app_modules_order_test.dart index 7732c79..7628966 100644 --- a/test/state/app_modules_order_test.dart +++ b/test/state/app_modules_order_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/session/session.dart'; import 'package:marianum_mobile/state/app/modules/app_modules.dart'; import 'package:marianum_mobile/storage/modules_settings.dart'; @@ -11,9 +12,7 @@ void main() { 'default position', () { // Regression: persisted settings from before the ticker module existed // repeatedly made new modules vanish from bar, "Mehr" and settings list. - final stale = Modules.values - .where((m) => m != Modules.ticker) - .toList(); + final stale = Modules.values.where((m) => m != Modules.ticker).toList(); final effective = AppModule.effectiveModuleOrder(settingsWith(stale)); @@ -72,9 +71,7 @@ void main() { // visible modules must not move or drop it (previously the raw persisted // indices were used, moving the wrong module). final effective = AppModule.effectiveModuleOrder(settingsWith([])); - final displayed = effective - .where((m) => m != Modules.ticker) - .toList(); + final displayed = effective.where((m) => m != Modules.ticker).toList(); final tickerSlot = effective.indexOf(Modules.ticker); final result = AppModule.reorderModuleOrder( @@ -92,4 +89,22 @@ void main() { ); }); }); + + group('isAvailableFor', () { + final student = CredentialSession(username: 'max', password: 'pw'); + const guardian = GuardianSession(email: 'e@x.de'); + + test('password accounts see every module', () { + for (final m in Modules.values) { + expect(AppModule.isAvailableFor(m, student), isTrue, reason: m.name); + } + }); + + test('guardians lose exactly the Nextcloud modules', () { + final hidden = Modules.values + .where((m) => !AppModule.isAvailableFor(m, guardian)) + .toSet(); + expect(hidden, {Modules.talk, Modules.files}); + }); + }); } diff --git a/test/state/primary_subject_test.dart b/test/state/primary_subject_test.dart new file mode 100644 index 0000000..50e8455 --- /dev/null +++ b/test/state/primary_subject_test.dart @@ -0,0 +1,169 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/api/marianumconnect/queries/get_capabilities/guardian_child.dart'; +import 'package:marianum_mobile/background/widget_background_task.dart'; +import 'package:marianum_mobile/session/session.dart'; +import 'package:marianum_mobile/state/app/modules/capabilities/bloc/capabilities_state.dart'; +import 'package:marianum_mobile/state/app/modules/children/child_selection_cubit.dart'; +import 'package:marianum_mobile/state/app/modules/timetable/policy/timetable_policy.dart'; +import 'package:marianum_mobile/state/app/modules/timetable/primary/primary_subject_resolver.dart'; +import 'package:marianum_mobile/state/app/modules/timetable/subject/timetable_subject.dart'; +import 'package:marianum_mobile/view/pages/absence_report/absence_form_policy.dart'; +import 'package:marianum_mobile/widget_data/widget_sync.dart'; + +const _anna = GuardianChild(id: 'a', firstName: 'Anna', lastName: 'X'); +const _ben = GuardianChild(id: 'b', firstName: 'Ben', lastName: 'X'); +const _guardian = GuardianSession(email: 'e@x.de'); +final _student = CredentialSession(username: 'max', password: 'pw'); + +void main() { + group('effectiveChild', () { + test('keeps a still-linked selection', () { + expect(effectiveChild([_anna, _ben], 'b'), _ben); + }); + + test('falls back to the first child', () { + expect(effectiveChild([_anna, _ben], null), _anna); + expect(effectiveChild([_anna, _ben], 'gone'), _anna); + }); + + test('is null without children', () { + expect(effectiveChild(const [], 'a'), isNull); + }); + }); + + group('resolvePrimarySubject', () { + test('password accounts see their own plan', () { + expect( + resolvePrimarySubject( + session: _student, + children: const [], + selectedChildId: null, + ), + const OwnTimetable(), + ); + }); + + test('guardians see the selected child', () { + expect( + resolvePrimarySubject( + session: _guardian, + children: const [_anna, _ben], + selectedChildId: 'b', + ), + const ChildTimetable('b'), + ); + }); + + test('no plan when signed out or without children', () { + expect( + resolvePrimarySubject( + session: null, + children: const [], + selectedChildId: null, + ), + const NoTimetable(), + ); + expect( + resolvePrimarySubject( + session: _guardian, + children: const [], + selectedChildId: null, + ), + const NoTimetable(), + ); + }); + }); + + group('child subject', () { + test('siblings get separate persistent slots', () { + expect( + const ChildTimetable('a').storageId, + isNot(const ChildTimetable('b').storageId), + ); + expect(const ChildTimetable('a').persistent, isTrue); + expect(const ChildTimetable('a').supportsCustomEvents, isFalse); + }); + + test('child plans offer no custom events', () { + final p = TimetablePolicy.resolve( + subject: const ChildTimetable('a'), + capabilities: const CapabilitiesState(userType: 'PARENT'), + ); + expect(p.canManageCustomEvents, isFalse); + expect(p.canEditSubjectColors, isTrue); + expect(p.canOpenForeign, isFalse); + }); + }); + + group('AbsenceFormPolicy', () { + test('users report for themselves with an editable identity', () { + final p = AbsenceFormPolicy.resolve( + session: _student, + children: const [], + selectedChildId: null, + )!; + expect(p.child, isNull); + expect(p.identityEditable, isTrue); + }); + + test('guardians report for the selected child', () { + final p = AbsenceFormPolicy.resolve( + session: _guardian, + children: const [_anna, _ben], + selectedChildId: 'b', + )!; + expect(p.child, _ben); + expect(p.identityEditable, isFalse); + }); + + test('guardians without children cannot report', () { + expect( + AbsenceFormPolicy.resolve( + session: _guardian, + children: const [], + selectedChildId: null, + ), + isNull, + ); + }); + }); + + group('widget subject', () { + test('round-trips primary subjects', () { + for (final subject in const [OwnTimetable(), ChildTimetable('a:b')]) { + expect( + WidgetSync.decodeSubject(WidgetSync.encodeSubject(subject)), + subject, + ); + } + }); + + test('a missing value is the own plan of older installs', () { + expect(WidgetSync.decodeSubject(null), const OwnTimetable()); + }); + + test('foreign and empty plans never back the widget', () { + expect(WidgetSync.encodeSubject(const NoTimetable()), isNull); + expect(WidgetSync.decodeSubject('child:'), isNull); + }); + + test('background refresh follows the session', () { + expect( + widgetRefreshSubject(session: _student, stored: null), + const OwnTimetable(), + ); + expect( + widgetRefreshSubject( + session: _guardian, + stored: const ChildTimetable('a'), + ), + const ChildTimetable('a'), + ); + // Before the app published a child, a guardian has nothing to fetch. + expect( + widgetRefreshSubject(session: _guardian, stored: const OwnTimetable()), + isNull, + ); + }); + }); +} diff --git a/test/state/timetable_subject_policy_test.dart b/test/state/timetable_subject_policy_test.dart new file mode 100644 index 0000000..cc6aa60 --- /dev/null +++ b/test/state/timetable_subject_policy_test.dart @@ -0,0 +1,133 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.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_rooms/timetable_get_rooms_response.dart'; +import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart'; +import 'package:marianum_mobile/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart'; +import 'package:marianum_mobile/state/app/modules/capabilities/bloc/capabilities_state.dart'; +import 'package:marianum_mobile/state/app/modules/timetable/bloc/timetable_state.dart'; +import 'package:marianum_mobile/state/app/modules/timetable/policy/timetable_policy.dart'; +import 'package:marianum_mobile/state/app/modules/timetable/subject/timetable_subject.dart'; + +ElementTimetable _element(TimetableElementType type, [int id = 7]) => + ElementTimetable((type: type, id: id, label: 'X')); + +void main() { + group('TimetableSubject', () { + test('own plan keeps the legacy storage slot and persists', () { + const own = OwnTimetable(); + expect(own.storageId, ''); + expect(own.persistent, isTrue); + expect(own.supportsCustomEvents, isTrue); + }); + + test('element plans get distinct slots and do not persist', () { + final room = _element(TimetableElementType.room, 1); + final teacher = _element(TimetableElementType.teacher, 1); + expect(room.storageId, isNot(teacher.storageId)); + expect(room.storageId, isNot(const OwnTimetable().storageId)); + expect(room.persistent, isFalse); + expect(room.supportsCustomEvents, isFalse); + }); + + test('element equality ignores the display label', () { + final a = ElementTimetable(( + type: TimetableElementType.room, + id: 3, + label: 'A', + )); + final b = ElementTimetable(( + type: TimetableElementType.room, + id: 3, + label: 'B', + )); + expect(a, b); + expect(a.hashCode, b.hashCode); + expect(a, isNot(_element(TimetableElementType.schoolClass, 3))); + }); + }); + + group('TimetablePolicy', () { + const student = CapabilitiesState(userType: 'STUDENT'); + const teacher = CapabilitiesState( + userType: 'TEACHER', + viewForeignTimetables: true, + ); + + test('own plan offers custom events and subject colours', () { + final p = TimetablePolicy.resolve( + subject: const OwnTimetable(), + capabilities: student, + ); + expect(p.canManageCustomEvents, isTrue); + expect(p.canEditSubjectColors, isTrue); + expect(p.canOpenForeign, isFalse); + expect(p.showClassInsteadOfTeacher, isFalse); + }); + + test('own teacher plan shows classes and may open foreign plans', () { + final p = TimetablePolicy.resolve( + subject: const OwnTimetable(), + capabilities: teacher, + ); + expect(p.showClassInsteadOfTeacher, isTrue); + expect(p.canOpenForeign, isTrue); + }); + + test('foreign plans are read-only', () { + final p = TimetablePolicy.resolve( + subject: _element(TimetableElementType.room), + capabilities: teacher, + ); + expect(p.canManageCustomEvents, isFalse); + expect(p.canEditSubjectColors, isFalse); + expect(p.canOpenForeign, isTrue); + expect(p.showClassInsteadOfTeacher, isFalse); + }); + + test('class labels follow the viewed element, not the viewer', () { + expect( + TimetablePolicy.resolve( + subject: _element(TimetableElementType.teacher), + capabilities: student, + ).showClassInsteadOfTeacher, + isTrue, + ); + expect( + TimetablePolicy.resolve( + subject: _element(TimetableElementType.student), + capabilities: teacher, + ).showClassInsteadOfTeacher, + isFalse, + ); + }); + }); + + group('TimetableState.isReady', () { + final empty = TimetableState( + startDate: DateTime(2026, 9, 14), + endDate: DateTime(2026, 9, 18), + ); + + final withReference = empty.copyWith( + rooms: TimetableGetRoomsResponse(result: const []), + subjects: TimetableGetSubjectsResponse(result: const []), + schoolHolidays: TimetableGetHolidaysResponse(result: const []), + ); + + test('is false without reference data', () { + expect(empty.isReady(needsCustomEvents: false), isFalse); + }); + + test('only waits for custom events when the subject needs them', () { + expect(withReference.isReady(needsCustomEvents: false), isTrue); + expect(withReference.isReady(needsCustomEvents: true), isFalse); + expect( + withReference + .copyWith(customEvents: GetCustomTimetableEventResponse(const [])) + .isReady(needsCustomEvents: true), + isTrue, + ); + }); + }); +} diff --git a/test/view/login/guardian_login_controller_test.dart b/test/view/login/guardian_login_controller_test.dart new file mode 100644 index 0000000..93f22be --- /dev/null +++ b/test/view/login/guardian_login_controller_test.dart @@ -0,0 +1,301 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/api/demo/demo_mode.dart'; +import 'package:marianum_mobile/api/marianumconnect/queries/auth_guardian/auth_guardian_request.dart'; +import 'package:marianum_mobile/api/marianumconnect/queries/auth_guardian/auth_guardian_verify.dart'; +import 'package:marianum_mobile/api/marianumconnect/queries/auth_guardian/guardian_login_exception.dart'; +import 'package:marianum_mobile/api/marianumconnect/queries/auth_login/auth_login_response.dart'; +import 'package:marianum_mobile/auth_link/device_binding.dart'; +import 'package:marianum_mobile/auth_link/guardian_login_link.dart'; +import 'package:marianum_mobile/auth_link/pending_guardian_request.dart'; +import 'package:marianum_mobile/session/session.dart'; +import 'package:marianum_mobile/view/login/guardian_login_controller.dart'; + +final _now = DateTime.utc(2026, 9, 19, 12); + +class _FakeStore implements PendingGuardianRequestStore { + PendingGuardianRequest? stored; + + @override + Future read() async => stored; + + @override + Future write(PendingGuardianRequest request) async => stored = request; + + @override + Future clear() async => stored = null; +} + +class _FakeRequest implements AuthGuardianRequest { + String? lastChallenge; + int calls = 0; + + @override + Future run({ + required String email, + required String deviceChallenge, + required String tokenName, + }) async { + calls++; + lastChallenge = deviceChallenge; + return AuthGuardianRequestResponse( + requestId: 'req-$calls', + expiresAt: _now.add(const Duration(minutes: 15)), + resendAvailableAt: _now.add(const Duration(seconds: 60)), + codeLength: 6, + ); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeVerify implements AuthGuardianVerify { + Object? failWith; + Map? lastCall; + + @override + Future run({ + required String requestId, + required String deviceVerifier, + required String tokenName, + String? code, + String? linkToken, + }) async { + lastCall = { + 'requestId': requestId, + 'deviceVerifier': deviceVerifier, + 'code': code, + 'linkToken': linkToken, + }; + final failure = failWith; + if (failure != null) throw failure; + return AuthLoginResponse( + token: 't', + tokenId: 'id', + expiresAt: null, + user: AuthLoginUser( + id: 'u', + username: 'e@x.de', + firstName: '', + lastName: '', + userType: 'PARENT', + className: null, + ), + ); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +GuardianLoginException _error(GuardianLoginError error) => + GuardianLoginException(error, userMessage: error.name); + +void main() { + late _FakeStore store; + late _FakeRequest request; + late _FakeVerify verify; + late List signedIn; + late DateTime now; + + GuardianLoginController controller() => GuardianLoginController( + request: request, + verify: verify, + store: store, + signIn: (s) async => signedIn.add(s), + tokenName: () async => 'test', + now: () => now, + ); + + setUp(() { + store = _FakeStore(); + request = _FakeRequest(); + verify = _FakeVerify(); + signedIn = []; + now = _now; + }); + + test( + 'requesting a code persists the request and moves to the code step', + () async { + final c = controller(); + expect(await c.requestCode(' E@X.de '), isFalse); + expect(c.step, GuardianLoginStep.enterCode); + expect(store.stored?.email, 'e@x.de'); + expect( + request.lastChallenge, + DeviceBinding.challengeFor(store.stored!.deviceSecret), + ); + }, + ); + + test( + 'a valid code signs in with the device secret and clears the request', + () async { + final c = controller(); + await c.requestCode('e@x.de'); + final secret = store.stored!.deviceSecret; + expect(await c.submitCode('123 456'), isTrue); + expect(verify.lastCall?['code'], '123456'); + expect(verify.lastCall?['deviceVerifier'], secret); + expect(store.stored, isNull); + expect(signedIn.single, isA()); + expect((signedIn.single as GuardianSession).isDemo, isFalse); + }, + ); + + test('a wrong code keeps the request for another try', () async { + final c = controller(); + await c.requestCode('e@x.de'); + verify.failWith = _error(GuardianLoginError.invalidCode); + expect(await c.submitCode('000000'), isFalse); + expect(c.step, GuardianLoginStep.enterCode); + expect(c.errorMessage, isNotNull); + expect(store.stored, isNotNull); + }); + + test('an expired request falls back to the e-mail step', () async { + final c = controller(); + await c.requestCode('e@x.de'); + verify.failWith = _error(GuardianLoginError.requestExpired); + await c.submitCode('123456'); + expect(c.step, GuardianLoginStep.enterEmail); + expect(store.stored, isNull); + }); + + test( + 'a link for another request is rejected without a server call', + () async { + final c = controller(); + await c.requestCode('e@x.de'); + final ok = await c.submitLink( + const GuardianLoginLink(requestId: 'other', linkToken: 'lt'), + ); + expect(ok, isFalse); + expect(verify.lastCall, isNull); + expect(c.errorMessage, contains('anderen Gerät')); + }, + ); + + test('a matching link signs in', () async { + final c = controller(); + await c.requestCode('e@x.de'); + final ok = await c.submitLink( + GuardianLoginLink(requestId: store.stored!.requestId, linkToken: 'lt'), + ); + expect(ok, isTrue); + expect(verify.lastCall?['linkToken'], 'lt'); + }); + + test('restore resumes an open request and drops an expired one', () async { + await controller().requestCode('e@x.de'); + final resumed = controller(); + await resumed.restore(); + expect(resumed.step, GuardianLoginStep.enterCode); + + now = _now.add(const Duration(hours: 1)); + final late = controller(); + await late.restore(); + expect(late.step, GuardianLoginStep.enterEmail); + expect(store.stored, isNull); + }); + + test('resend is only possible after the cooldown', () async { + final c = controller(); + await c.requestCode('e@x.de'); + expect(c.canResend(), isFalse); + await c.resend(); + expect(request.calls, 1); + now = _now.add(const Duration(seconds: 61)); + expect(c.canResend(), isTrue); + await c.resend(); + expect(request.calls, 2); + }); + + test('the demo address signs in locally', () async { + final c = controller(); + expect(await c.requestCode(DemoMode.guardianEmail), isTrue); + expect(request.calls, 0); + expect(signedIn.single.isDemo, isTrue); + }); + + group('GuardianLoginException.fromDio', () { + DioException failure(int status, Object? body) => DioException( + requestOptions: RequestOptions(), + type: DioExceptionType.badResponse, + response: Response( + requestOptions: RequestOptions(), + statusCode: status, + data: body, + ), + ); + + test('reads the JSON error body', () { + final e = + GuardianLoginException.fromDio( + failure(401, {'error': 'invalid_code', 'attemptsLeft': 2}), + ) + as GuardianLoginException; + expect(e.error, GuardianLoginError.invalidCode); + expect(e.attemptsLeft, 2); + expect(e.userMessage, contains('Noch 2 Versuche')); + }); + + test('reads the plain-text error of the generic server handler', () { + final e = + GuardianLoginException.fromDio( + failure(403, 'Fehler: device_mismatch'), + ) + as GuardianLoginException; + expect(e.error, GuardianLoginError.deviceMismatch); + }); + + test('falls back to the status code', () { + final e = + GuardianLoginException.fromDio(failure(429, null)) + as GuardianLoginException; + expect(e.error, GuardianLoginError.rateLimited); + }); + + test('names an address the school has no guardian access for', () { + final e = + GuardianLoginException.fromDio( + failure(404, {'error': 'email_not_registered'}), + ) + as GuardianLoginException; + expect(e.error, GuardianLoginError.emailNotRegistered); + expect(e.userMessage, contains('Sekretariat')); + }); + + test('a bare 404 means the server lacks guardian login, not an ' + 'unknown address', () { + for (final body in [null, 'Not Found', 'Endpoint not found']) { + final e = + GuardianLoginException.fromDio(failure(404, body)) + as GuardianLoginException; + expect(e.error, GuardianLoginError.unsupportedServer, reason: '$body'); + } + final methodMissing = + GuardianLoginException.fromDio(failure(405, null)) + as GuardianLoginException; + expect(methodMissing.error, GuardianLoginError.unsupportedServer); + }); + + test('names a disabled guardian account', () { + final e = + GuardianLoginException.fromDio( + failure(403, {'error': 'account_disabled'}), + ) + as GuardianLoginException; + expect(e.error, GuardianLoginError.accountDisabled); + }); + + test('server errors keep the generic mapping', () { + expect( + GuardianLoginException.fromDio(failure(500, 'boom')), + isNot(isA()), + ); + }); + }); +}