From 84098af7e226055b95a6b2fd65df2cb122c10b2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Wed, 23 Sep 2026 20:50:12 +0200 Subject: [PATCH] added support for multiple accounts, guardian login bugfixes, ui changes --- CLAUDE.md | 2 + .../app_password/delete_app_password.dart | 13 +- lib/api/marianumcloud/webdav/webdav_api.dart | 3 +- .../auth/auth_interceptor.dart | 11 + .../auth/session_validator.dart | 8 +- .../marianumconnect/auth/token_storage.dart | 20 + .../queries/auth_logout/auth_logout.dart | 15 + .../queries/auth_me/auth_me.dart | 12 +- .../get_capabilities/guardian_child.dart | 4 + .../guardian_child.freezed.dart | 47 +- .../get_capabilities/guardian_child.g.dart | 2 + lib/main.dart | 408 +++++++++--------- lib/push/push_registration.dart | 8 + lib/routing/app_routes.dart | 4 +- lib/session/account_codec.dart | 221 ++++++++++ lib/session/session_lifecycle.dart | 183 +++++++- lib/session/session_manager.dart | 125 +++++- lib/share_intent/share_intent_listener.dart | 16 +- .../app/modules/account/account_scope.dart | 71 +++ .../modules/account/bloc/account_bloc.dart | 32 +- .../modules/account/bloc/account_event.dart | 6 + .../modules/account/bloc/account_state.dart | 17 +- .../modules/settings/bloc/settings_cubit.dart | 2 +- .../primary/primary_timetable_scope.dart | 10 +- lib/storage/account_storage.dart | 87 ++++ lib/view/login/login.dart | 184 +++++--- lib/view/login/login_controller.dart | 9 +- .../login/widgets/guardian_login_card.dart | 10 +- .../login/widgets/login_audience_card.dart | 21 +- lib/view/login/widgets/login_branding.dart | 10 +- lib/view/login/widgets/login_card.dart | 13 +- .../settings/sections/account_section.dart | 142 ++++-- lib/widget/account_switcher_sheet.dart | 193 +++++++++ lib/widget/child_switcher.dart | 35 +- lib/widget/user_avatar.dart | 4 +- test/session/account_codec_test.dart | 162 +++++++ test/share_intent/app_link_filter_test.dart | 25 ++ 37 files changed, 1759 insertions(+), 376 deletions(-) create mode 100644 lib/session/account_codec.dart create mode 100644 lib/state/app/modules/account/account_scope.dart create mode 100644 lib/storage/account_storage.dart create mode 100644 lib/widget/account_switcher_sheet.dart create mode 100644 test/session/account_codec_test.dart create mode 100644 test/share_intent/app_link_filter_test.dart diff --git a/CLAUDE.md b/CLAUDE.md index e69a821..62e8e27 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,6 +56,8 @@ lib/ **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). +**Mehrere Konten:** Die `SessionKeys`-Slots, der MC-Token und die Group-Keychain spiegeln immer das *aktive* Konto; inaktive Konten liegen im Tresor (`accounts_index` + `account_vault_`, `lib/session/account_codec.dart`). Wechseln/Hinzufügen/Entfernen nur über `SessionLifecycle` (`switchTo`, `beginAddAccount`/`finishLogin`/`cancelAddAccount`, `removeInactive`), danach `AccountBloc.activated(id)`. Lokale Daten sind pro Konto getrennt (`AccountStorage`: eigene HydratedBloc-Box + Localstore-Collection); nur die Settings liegen in der globalen Box. Alle Pro-Konto-Blocs gehören in `AccountScope` – er wird per Konto-ID neu aufgebaut (samt `MaterialApp`/Navigator). Push ist nur für das aktive Konto registriert. + **Rollen & Zuschnitt:** Views verzweigen nie auf Rollen. Module und Settings-Sections deklarieren `AccessRequirement`s (`nextcloud`, `guardian`; `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. diff --git a/lib/api/marianumcloud/app_password/delete_app_password.dart b/lib/api/marianumcloud/app_password/delete_app_password.dart index 12cbcd9..3726c86 100644 --- a/lib/api/marianumcloud/app_password/delete_app_password.dart +++ b/lib/api/marianumcloud/app_password/delete_app_password.dart @@ -16,10 +16,15 @@ class DeleteAppPassword { Future run({String? authorizationHeader}) async { await _client.delete( NextcloudOcs.uri('core/apppassword'), - headers: { - ...NextcloudOcs.headers(), - 'Authorization': ?authorizationHeader, - }, + // An explicit header may belong to an inactive account, so the active + // session's headers must not be required then. + headers: authorizationHeader == null + ? NextcloudOcs.headers() + : { + 'Accept': 'application/json', + 'OCS-APIRequest': 'true', + 'Authorization': authorizationHeader, + }, ); } } diff --git a/lib/api/marianumcloud/webdav/webdav_api.dart b/lib/api/marianumcloud/webdav/webdav_api.dart index 9b79797..306d945 100644 --- a/lib/api/marianumcloud/webdav/webdav_api.dart +++ b/lib/api/marianumcloud/webdav/webdav_api.dart @@ -18,7 +18,8 @@ abstract class WebdavApi { /// changes (app password minted/renewed, account switch) so it never keeps /// authenticating with stale credentials. static Future get webdav { - final secret = SessionManager().requireNextcloud().secret; + final nextcloud = SessionManager().requireNextcloud(); + final secret = '${nextcloud.username}:${nextcloud.secret}'; if (_webdav == null || _webdavSecret != secret) { _webdavSecret = secret; _webdav = establishWebdavConnection(); diff --git a/lib/api/marianumconnect/auth/auth_interceptor.dart b/lib/api/marianumconnect/auth/auth_interceptor.dart index 90338f5..75fdbb9 100644 --- a/lib/api/marianumconnect/auth/auth_interceptor.dart +++ b/lib/api/marianumconnect/auth/auth_interceptor.dart @@ -20,6 +20,15 @@ class MarianumConnectAuthInterceptor extends Interceptor { // each spawning a fresh row in api_tokens. Future? _pendingReLogin; + static Future? _anyPendingReLogin; + + /// Resolves once no silent re-login is running. An account switch waits for + /// it — the renewed token would otherwise land in the next account's slot. + static Future idle() async { + final pending = _anyPendingReLogin; + if (pending != null) await pending; + } + MarianumConnectAuthInterceptor({ MarianumConnectTokenStorage tokenStorage = const MarianumConnectTokenStorage(), @@ -85,8 +94,10 @@ class MarianumConnectAuthInterceptor extends Interceptor { if (inFlight != null) return inFlight; final fresh = _performReLogin(); _pendingReLogin = fresh; + _anyPendingReLogin = fresh; fresh.whenComplete(() { if (identical(_pendingReLogin, fresh)) _pendingReLogin = null; + if (identical(_anyPendingReLogin, fresh)) _anyPendingReLogin = null; }); return fresh; } diff --git a/lib/api/marianumconnect/auth/session_validator.dart b/lib/api/marianumconnect/auth/session_validator.dart index bfef92d..5b3b1ec 100644 --- a/lib/api/marianumconnect/auth/session_validator.dart +++ b/lib/api/marianumconnect/auth/session_validator.dart @@ -10,10 +10,10 @@ import '../queries/auth_verify/auth_verify.dart'; /// 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. +/// session is dropped. Another stored account then takes over. class SessionValidator { static Future probeStored({ - required Future Function() onInvalidated, + required Future Function(String? nextAccountId) onInvalidated, }) async { final session = SessionManager().current; // The probes use their own dio (bypassing the demo interceptor), so a demo @@ -29,7 +29,7 @@ class SessionValidator { } on AuthException catch (e) { if (e.statusCode != 401) return; log('MC: stored session rejected — forcing re-login'); - await SessionLifecycle.signOut( + final next = await SessionLifecycle.signOut( notice: switch (session) { CredentialSession() => 'Deine Zugangsdaten wurden vom Server abgelehnt. Vermutlich ' @@ -38,7 +38,7 @@ class SessionValidator { 'Deine Anmeldung ist abgelaufen. Bitte melde dich erneut an.', }, ); - await onInvalidated(); + await onInvalidated(next); } catch (e) { log('MC: background session check failed (transient): $e'); } diff --git a/lib/api/marianumconnect/auth/token_storage.dart b/lib/api/marianumconnect/auth/token_storage.dart index 24e85c8..4692dae 100644 --- a/lib/api/marianumconnect/auth/token_storage.dart +++ b/lib/api/marianumconnect/auth/token_storage.dart @@ -60,6 +60,26 @@ class MarianumConnectTokenStorage { ); } + static const bearerKey = _tokenKey; + static const fieldKeys = [_tokenKey, _tokenIdKey, _expiresAtKey]; + + /// Raw stored fields, for parking the token of an inactive account. + Future> readAll() async => { + for (final key in fieldKeys) key: ?await _storage.read(key: key), + }; + + /// Restores fields from [readAll]; missing ones are deleted. + Future writeAll(Map fields) async { + for (final key in fieldKeys) { + final value = fields[key]; + if (value == null) { + await _storage.delete(key: key); + } else { + await _storage.write(key: key, value: value); + } + } + } + Future clear() async { await _storage.delete(key: _tokenKey); await _storage.delete(key: _tokenIdKey); diff --git a/lib/api/marianumconnect/queries/auth_logout/auth_logout.dart b/lib/api/marianumconnect/queries/auth_logout/auth_logout.dart index e96d581..dad824a 100644 --- a/lib/api/marianumconnect/queries/auth_logout/auth_logout.dart +++ b/lib/api/marianumconnect/queries/auth_logout/auth_logout.dart @@ -1,6 +1,8 @@ import 'package:dio/dio.dart'; import '../../auth/token_storage.dart'; +import '../../marianumconnect_api.dart'; +import '../../marianumconnect_endpoint.dart'; import '../../marianumconnect_query.dart'; /// Revokes the stored MC bearer token both server-side and locally. Best-effort @@ -24,4 +26,17 @@ class AuthLogout extends MarianumConnectQuery { await _tokenStorage.clear(); } } + + /// Revokes the token of an inactive account; the stored (active) token is + /// left alone. Best-effort. + static Future revoke(String token) async { + try { + await MarianumConnectApi.plainDio().post( + MarianumConnectEndpoint.resolve('auth/logout'), + options: Options(headers: {'Authorization': 'Bearer $token'}), + ); + } on DioException catch (_) { + // ignore + } + } } diff --git a/lib/api/marianumconnect/queries/auth_me/auth_me.dart b/lib/api/marianumconnect/queries/auth_me/auth_me.dart index 2fe75ba..1cfb3b4 100644 --- a/lib/api/marianumconnect/queries/auth_me/auth_me.dart +++ b/lib/api/marianumconnect/queries/auth_me/auth_me.dart @@ -4,6 +4,7 @@ import '../../../errors/auth_exception.dart'; import '../../auth/token_storage.dart'; import '../../marianumconnect_api.dart'; import '../../marianumconnect_query.dart'; +import '../auth_login/auth_login_response.dart'; /// Probes that the stored bearer token is still accepted. Used for accounts /// without a password (guardians), whose token cannot be renewed silently. @@ -22,9 +23,18 @@ class AuthMe extends MarianumConnectQuery { /// Throws [AuthException] when the token is missing or rejected. Future run() async { + await user(); + } + + /// The signed-in user (names, type). Throws like [run]. + Future user() async { final options = await _tokenStorage.requireBearerOptions('AuthMe'); return guard(() async { - await dio.get(endpoint('auth/me'), options: options); + final response = await dio.get>( + endpoint('auth/me'), + options: options, + ); + return AuthLoginUser.fromJson(response.data!); }); } } diff --git a/lib/api/marianumconnect/queries/get_capabilities/guardian_child.dart b/lib/api/marianumconnect/queries/get_capabilities/guardian_child.dart index a9f4337..fe70ec2 100644 --- a/lib/api/marianumconnect/queries/get_capabilities/guardian_child.dart +++ b/lib/api/marianumconnect/queries/get_capabilities/guardian_child.dart @@ -14,6 +14,10 @@ abstract class GuardianChild with _$GuardianChild { required String firstName, required String lastName, @Default('') String className, + + /// Login of the child, also its Nextcloud id (avatar). Null on servers + /// that do not send it yet. + String? username, }) = _GuardianChild; factory GuardianChild.fromJson(Map json) => diff --git a/lib/api/marianumconnect/queries/get_capabilities/guardian_child.freezed.dart b/lib/api/marianumconnect/queries/get_capabilities/guardian_child.freezed.dart index 20dd15d..898302f 100644 --- a/lib/api/marianumconnect/queries/get_capabilities/guardian_child.freezed.dart +++ b/lib/api/marianumconnect/queries/get_capabilities/guardian_child.freezed.dart @@ -16,7 +16,9 @@ T _$identity(T value) => value; /// @nodoc mixin _$GuardianChild { - String get id; String get firstName; String get lastName; String get className; + String get id; String get firstName; String get lastName; String get className;/// Login of the child, also its Nextcloud id (avatar). Null on servers +/// that do not send it yet. + String? get username; /// Create a copy of GuardianChild /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -30,20 +32,20 @@ $GuardianChildCopyWith get copyWith => _$GuardianChildCopyWithImp @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)); + 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)&&(identical(other.username, _this.username) || other.username == _this.username)); } @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); + return Object.hash(runtimeType,_this.id,_this.firstName,_this.lastName,_this.className,_this.username); } @override String toString() { final _this = this as GuardianChild; - return 'GuardianChild(id: ${_this.id}, firstName: ${_this.firstName}, lastName: ${_this.lastName}, className: ${_this.className})'; + return 'GuardianChild(id: ${_this.id}, firstName: ${_this.firstName}, lastName: ${_this.lastName}, className: ${_this.className}, username: ${_this.username})'; } @@ -54,7 +56,7 @@ 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 + String id, String firstName, String lastName, String className, String? username }); @@ -71,13 +73,14 @@ class _$GuardianChildCopyWithImpl<$Res> /// 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,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? firstName = null,Object? lastName = null,Object? className = null,Object? username = freezed,}) { 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, +as String,username: freezed == username ? _self.username : username // ignore: cast_nullable_to_non_nullable +as String?, )); } @@ -162,10 +165,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( String id, String firstName, String lastName, String className)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( String id, String firstName, String lastName, String className, String? username)? $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 $default(_that.id,_that.firstName,_that.lastName,_that.className,_that.username);case _: return orElse(); } @@ -183,10 +186,10 @@ return $default(_that.id,_that.firstName,_that.lastName,_that.className);case _: /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( String id, String firstName, String lastName, String className) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( String id, String firstName, String lastName, String className, String? username) $default,) {final _that = this; switch (_that) { case _GuardianChild(): -return $default(_that.id,_that.firstName,_that.lastName,_that.className);case _: +return $default(_that.id,_that.firstName,_that.lastName,_that.className,_that.username);case _: throw StateError('Unexpected subclass'); } @@ -203,10 +206,10 @@ return $default(_that.id,_that.firstName,_that.lastName,_that.className);case _: /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, String firstName, String lastName, String className)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, String firstName, String lastName, String className, String? username)? $default,) {final _that = this; switch (_that) { case _GuardianChild() when $default != null: -return $default(_that.id,_that.firstName,_that.lastName,_that.className);case _: +return $default(_that.id,_that.firstName,_that.lastName,_that.className,_that.username);case _: return null; } @@ -218,13 +221,16 @@ return $default(_that.id,_that.firstName,_that.lastName,_that.className);case _: @JsonSerializable() class _GuardianChild extends GuardianChild { - const _GuardianChild({required this.id, required this.firstName, required this.lastName, this.className = ''}): super._(); + const _GuardianChild({required this.id, required this.firstName, required this.lastName, this.className = '', this.username}): 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; +/// Login of the child, also its Nextcloud id (avatar). Null on servers +/// that do not send it yet. +@override final String? username; /// Create a copy of GuardianChild /// with the given fields replaced by the non-null parameter values. @@ -239,18 +245,18 @@ Map toJson() { @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)); + 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)&&(identical(other.username, username) || other.username == username)); } @JsonKey(includeFromJson: false, includeToJson: false) @override int get hashCode { - return Object.hash(runtimeType,id,firstName,lastName,className); + return Object.hash(runtimeType,id,firstName,lastName,className,username); } @override String toString() { - return 'GuardianChild(id: $id, firstName: $firstName, lastName: $lastName, className: $className)'; + return 'GuardianChild(id: $id, firstName: $firstName, lastName: $lastName, className: $className, username: $username)'; } @@ -261,7 +267,7 @@ abstract mixin class _$GuardianChildCopyWith<$Res> implements $GuardianChildCopy factory _$GuardianChildCopyWith(_GuardianChild value, $Res Function(_GuardianChild) _then) = __$GuardianChildCopyWithImpl; @override @useResult $Res call({ - String id, String firstName, String lastName, String className + String id, String firstName, String lastName, String className, String? username }); @@ -278,13 +284,14 @@ class __$GuardianChildCopyWithImpl<$Res> /// 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,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? firstName = null,Object? lastName = null,Object? className = null,Object? username = freezed,}) { 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, +as String,username: freezed == username ? _self.username : username // ignore: cast_nullable_to_non_nullable +as String?, )); } diff --git a/lib/api/marianumconnect/queries/get_capabilities/guardian_child.g.dart b/lib/api/marianumconnect/queries/get_capabilities/guardian_child.g.dart index bd9554b..4409bfc 100644 --- a/lib/api/marianumconnect/queries/get_capabilities/guardian_child.g.dart +++ b/lib/api/marianumconnect/queries/get_capabilities/guardian_child.g.dart @@ -12,6 +12,7 @@ _GuardianChild _$GuardianChildFromJson(Map json) => firstName: json['firstName'] as String, lastName: json['lastName'] as String, className: json['className'] as String? ?? '', + username: json['username'] as String?, ); Map _$GuardianChildToJson(_GuardianChild instance) => @@ -20,4 +21,5 @@ Map _$GuardianChildToJson(_GuardianChild instance) => 'firstName': instance.firstName, 'lastName': instance.lastName, 'className': instance.className, + 'username': instance.username, }; diff --git a/lib/main.dart b/lib/main.dart index 29d9be6..99509a2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,7 +10,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; -import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:jiffy/jiffy.dart'; import 'package:loader_overlay/loader_overlay.dart'; import 'package:path_provider/path_provider.dart'; @@ -34,20 +33,19 @@ import 'push/push_registration.dart'; import 'push/push_registration_store.dart'; import 'push/push_renderer.dart'; import 'routing/app_routes.dart'; +import 'session/account_codec.dart'; +import 'session/session_lifecycle.dart'; import 'session/session_manager.dart'; import 'share_intent/share_intent_listener.dart'; +import 'state/app/modules/account/account_scope.dart'; import 'state/app/modules/account/bloc/account_bloc.dart'; import 'state/app/modules/account/bloc/account_state.dart'; -import 'state/app/modules/breaker/bloc/breaker_bloc.dart'; import 'state/app/modules/capabilities/bloc/capabilities_cubit.dart'; -import 'state/app/modules/chat/bloc/chat_bloc.dart'; import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart'; -import 'state/app/modules/children/child_selection_cubit.dart'; import 'state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart'; import 'state/app/modules/parent_letters/bloc/parent_letters_bloc.dart'; import 'state/app/modules/settings/bloc/settings_cubit.dart'; -import 'state/app/modules/timetable/primary/primary_timetable_scope.dart'; -import 'storage/hydrated_storage_bootstrap.dart'; +import 'storage/account_storage.dart'; import 'storage/settings.dart'; import 'theming/dark_app_theme.dart'; import 'theming/light_app_theme.dart'; @@ -59,9 +57,9 @@ import 'view/login/login.dart'; import 'view/login/post_login_splash.dart'; import 'widget/avatar_disk_cache.dart'; import 'widget/breaker/breaker.dart'; -import 'widget/debug/cache_view.dart'; import 'widget/downloads/download_tray.dart'; import 'widget/emergency/emergency_notice_gate.dart'; +import 'widget/info_dialog.dart'; import 'widget_data/widget_sync.dart'; /// Runs one startup step with a time limit. Anything that throws or hangs @@ -148,8 +146,7 @@ Future main() async { ]), ), _startupStep('hydrated storage', () async { - final path = (await getTemporaryDirectory()).path; - HydratedBloc.storage = await buildHydratedStorageWithFallback(path); + await AccountStorage.init((await getTemporaryDirectory()).path); }, timeout: null), _startupStep('documents dir', () async { AppPaths.documentsDir = (await getApplicationDocumentsDirectory()).path; @@ -170,6 +167,16 @@ Future main() async { log('starting app initialisation...'); await Future.wait(initialisationTasks); + // Snapshot: the session may still finish loading later (see _MainState), + // the bloc and the storage must agree on the account they start with. + final initialAccount = SessionManager().isLoaded + ? SessionManager().activeAccount + : null; + await _startupStep( + 'account storage', + () => AccountStorage.activate(initialAccount), + timeout: null, + ); log('app initialisation done!'); // Local notifications: init the plugin (with tap/action callbacks) and the @@ -236,7 +243,7 @@ Future main() async { // Created eagerly so the endpoint is configured before anything below can // issue a request (the primary timetable bloc loads on creation). - final settingsCubit = SettingsCubit(); + final settingsCubit = SettingsCubit(storage: AccountStorage.global); _syncMarianumConnectEndpoint(settingsCubit.state); settingsCubit.stream.listen(_syncMarianumConnectEndpoint); @@ -246,21 +253,13 @@ Future main() async { providers: [ BlocProvider.value(value: settingsCubit), BlocProvider( - create: (_) => AccountBloc(initialStatus: _initialAccountStatus()), + create: (_) => AccountBloc( + initialStatus: _initialAccountStatus(initialAccount), + accountId: initialAccount?.id, + ), ), - BlocProvider(create: (_) => BreakerBloc()), - BlocProvider(create: (_) => CapabilitiesCubit()), - BlocProvider( - create: (_) => NextcloudCapabilitiesCubit(), - ), - BlocProvider(create: (_) => ChatListBloc()), - BlocProvider( - create: (ctx) => ChatBloc(chatListBloc: ctx.read()), - ), - BlocProvider(create: (_) => ChildSelectionCubit()), - BlocProvider(create: (_) => ParentLettersBloc()), ], - child: const PrimaryTimetableScope(child: Main()), + child: const Main(), ), ); } @@ -279,10 +278,11 @@ void _syncMarianumConnectEndpoint(Settings settings) { unawaited(WidgetSync.setMarianumConnectBaseUrl(url)); } -AccountStatus _initialAccountStatus() { - final session = SessionManager(); - if (session.isSignedIn) return AccountStatus.loggedIn; - return session.isLoaded ? AccountStatus.loggedOut : AccountStatus.undefined; +AccountStatus _initialAccountStatus(AccountEntry? account) { + if (account != null) return AccountStatus.loggedIn; + return SessionManager().isLoaded + ? AccountStatus.loggedOut + : AccountStatus.undefined; } class Main extends StatefulWidget { @@ -306,26 +306,30 @@ class _MainState extends State
{ Jiffy.setLocale('de'); SessionManager().unauthorizedSignal.addListener(_onUnauthorized); - SessionManager().waitForLoad().then((session) { + GuardianLinkListener.pending.addListener(_onGuardianLink); + SessionManager().waitForLoad().then((session) async { if (!mounted) return; final accountBloc = context.read(); - accountBloc.setStatus( - session != null ? AccountStatus.loggedIn : AccountStatus.loggedOut, - ); - if (session != null) { - _scheduleSessionValidation(accountBloc); - // Cold start while already logged in: the account status doesn't - // change, so the loggedIn listener below never fires. - _onSessionActive(); + if (session == null) { + accountBloc.setStatus(AccountStatus.loggedOut); + return; } + // Covers a session that finished loading after the startup snapshot; + // otherwise the id is unchanged and nothing remounts. + final account = SessionManager().activeAccount; + await AccountStorage.activate(account); + if (!mounted) return; + accountBloc.activated(account?.id); + _scheduleSessionValidation(accountBloc); + unawaited(_onGuardianLink()); }); } - /// 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(); + /// Runs whenever the account-scoped tree mounts for a signed-in account: + /// pulls the capability flags, then registers push for this account. + void _onSessionActive(BuildContext scopeContext) { + final settingsCubit = scopeContext.read(); + final capabilitiesCubit = scopeContext.read(); unawaited( capabilitiesCubit.load().then((_) { if (!mounted) return; @@ -333,7 +337,20 @@ class _MainState extends State
{ _promptGuardianNotifications(); }), ); - unawaited(context.read().load()); + unawaited(scopeContext.read().load()); + unawaited(SessionLifecycle.refreshDisplayName()); + _prefetchBaseData(scopeContext); + WidgetsBinding.instance.addPostFrameCallback((_) => _showTakeoverNotice()); + } + + /// A forced sign-out that handed over to another account never reaches the + /// login screen, which normally explains it. + void _showTakeoverNotice() { + final notice = SessionLifecycle.signOutNotice.value; + final overlayContext = AppRoutes.overlayContext; + if (notice == null || overlayContext == null) return; + SessionLifecycle.signOutNotice.value = null; + InfoDialog.show(overlayContext, notice, title: 'Abgemeldet'); } /// Waits for the post-login splash so the dialog never covers it; the @@ -379,9 +396,20 @@ class _MainState extends State
{ @override void dispose() { SessionManager().unauthorizedSignal.removeListener(_onUnauthorized); + GuardianLinkListener.pending.removeListener(_onGuardianLink); super.dispose(); } + /// A guardian login link tapped while signed in belongs to an "add account" + /// flow (the app may have been killed meanwhile): reopen the login for it. + Future _onGuardianLink() async { + if (GuardianLinkListener.pending.value == null || !mounted) return; + final accountBloc = context.read(); + if (accountBloc.state.status != AccountStatus.loggedIn) return; + await SessionLifecycle.beginAddAccount(); + accountBloc.setStatus(AccountStatus.addingAccount); + } + void _onUnauthorized() { if (!mounted) return; final accountBloc = context.read(); @@ -394,25 +422,52 @@ class _MainState extends State
{ } /// Background credential check: a 401 means the password was rotated - /// server-side, so the validator wipes the local session and flips the - /// account bloc to `loggedOut` (sending the user to the login screen). + /// server-side, so the validator signs the account out and the app moves + /// on to another stored account or the login screen. void _scheduleSessionValidation(AccountBloc accountBloc) { unawaited( SessionValidator.probeStored( - onInvalidated: () async { + onInvalidated: (nextAccountId) async { if (!mounted) return; - accountBloc.setStatus(AccountStatus.loggedOut); + accountBloc.activated(nextAccountId); }, ), ); } + void _onAccountChanged(BuildContext context, AccountState accountState) { + if (accountState.status == AccountStatus.loggedIn && + accountState.freshLogin) { + _showPostLoginSplash = true; + _appMounted = false; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() => _appMounted = true); + }); + } + if (accountState.status == AccountStatus.addingAccount) { + // Routes pushed on the root navigator (Settings) would otherwise keep + // covering the login screen that replaces the home route. + AppRoutes.rootNavigatorKey.currentState?.popUntil( + (route) => route.isFirst, + ); + } + if (accountState.status != AccountStatus.loggedOut) return; + // A pending share would otherwise survive logout and be re-applied after + // re-login with file paths the OS may already have evicted from the cache. + ShareIntentListener.instance.clear(); + // Deferred until the account-scoped tree is torn down. + WidgetsBinding.instance.addPostFrameCallback( + (_) => unawaited(_wipeDeviceState()), + ); + } + + String? _scopeAccountId; + @override Widget build(BuildContext context) => Directionality( textDirection: TextDirection.ltr, child: BlocBuilder( builder: (context, settings) { - final devToolsSettings = settings.devToolsSettings; // Mirror the notification toggle into group-scoped storage so the FCM // background isolate and the iOS NSE can suppress rendering when off. unawaited( @@ -420,172 +475,115 @@ class _MainState extends State
{ settings.notificationSettings.enabled, ), ); - return MaterialApp( - showPerformanceOverlay: devToolsSettings.showPerformanceOverlay, - checkerboardOffscreenLayers: - devToolsSettings.checkerboardOffscreenLayers, - checkerboardRasterCacheImages: - devToolsSettings.checkerboardRasterCacheImages, - debugShowCheckedModeBanner: false, - navigatorKey: AppRoutes.rootNavigatorKey, - // Used by ChatView.didPopNext to reclaim the global ChatBloc. - // DownloadRouteObserver tracks full-page navigations so the downloads - // chip only surfaces once the user leaves the screen they started on. - navigatorObservers: [ - AppRoutes.chatRouteObserver, - DownloadRouteObserver(), - ], - localizationsDelegates: const [ - ...GlobalMaterialLocalizations.delegates, - GlobalWidgetsLocalizations.delegate, - ], - supportedLocales: const [Locale('de'), Locale('en')], - locale: const Locale('de'), - title: 'Marianum Fulda', - themeMode: settings.appTheme, - theme: LightAppTheme.theme, - darkTheme: DarkAppTheme.theme, - // Brand-colored backdrop behind every route. During the logout - // home-swap and route pop animations the framework can briefly - // expose the layer below the topmost Scaffold; without this - // the dark Material default shows through and the user sees a - // black flash. - builder: (context, child) => ColoredBox( - color: LightAppTheme.marianumRed, - // Downloads tray mounted ABOVE the navigator so its chip floats over - // every route (folder views, chat, viewer are full-page pushes that - // would otherwise cover it). - child: DownloadTrayHost(child: child ?? const SizedBox.shrink()), - ), - home: EmergencyNoticeGate( - child: LoaderOverlay( - child: Breaker( - breaker: BreakerArea.global, - child: BlocConsumer( - listenWhen: (previous, current) => - previous.status != current.status, - listener: (context, accountState) { - if (accountState.status == AccountStatus.loggedIn) { - _onSessionActive(); - _showPostLoginSplash = true; - _appMounted = false; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) setState(() => _appMounted = true); - }); - _prefetchBaseData(context); - } - if (accountState.status != AccountStatus.loggedOut) return; - // A pending share would otherwise survive logout and be - // re-applied after re-login with file paths the OS may - // already have evicted from the cache. - ShareIntentListener.instance.clear(); - // Routes pushed via AppRoutes (e.g. Settings) live on the - // root navigator and survive the home swap below, so they - // would still cover the Login screen after logout. Pop - // them here so the user immediately sees Login. - final navigator = Navigator.of(context); - if (navigator.canPop()) { - navigator.popUntil((route) => route.isFirst); - } - // Capture bloc references before the post-frame callback - // — by the time it runs the dialog/Settings context is - // gone but this listener context is still valid. - final settingsCubit = context.read(); - final breakerBloc = context.read(); - final capabilitiesCubit = context.read(); - final childSelectionCubit = context - .read(); - final chatListBloc = context.read(); - final parentLettersBloc = context.read(); - final chatBloc = context.read(); - final nextcloudCapabilitiesCubit = context - .read(); - // Defer the actual wipe until after this frame so the - // App tree (TimetableBloc/ChatListBloc watchers etc.) - // is already torn down. Resetting blocs while App is - // still in front caused a black-frame race. - WidgetsBinding.instance.addPostFrameCallback((_) { - unawaited( - _wipeUserState( - settingsCubit: settingsCubit, - childSelectionCubit: childSelectionCubit, - chatListBloc: chatListBloc, - parentLettersBloc: parentLettersBloc, - chatBloc: chatBloc, - breakerBloc: breakerBloc, - capabilitiesCubit: capabilitiesCubit, - nextcloudCapabilitiesCubit: - nextcloudCapabilitiesCubit, - ), - ); - }); - }, - builder: (context, accountState) { - switch (accountState.status) { - case AccountStatus.loggedIn: - return Stack( - fit: StackFit.expand, - children: [ - if (_appMounted) - const App(key: ValueKey('app-shell')), - if (_showPostLoginSplash) - PostLoginSplash( - key: const ValueKey('post-login-splash'), - onComplete: () { - setState(() => _showPostLoginSplash = false); - _promptGuardianNotifications(); - }, - ), - ], - ); - case AccountStatus.loggedOut: - return const Login(); - case AccountStatus.undefined: - return const AccountLoadingScreen(); - } - }, - ), - ), - ), - ), + return BlocConsumer( + listenWhen: (previous, current) => + previous.status != current.status || + previous.accountId != current.accountId, + listener: _onAccountChanged, + buildWhen: (previous, current) => + previous.accountId != current.accountId, + builder: (context, account) { + if (account.accountId != _scopeAccountId) { + _scopeAccountId = account.accountId; + // The old navigator (and every route on it) goes with the old + // account; a shared GlobalKey would carry it over instead. + AppRoutes.rootNavigatorKey = GlobalKey(); + } + return AccountScope( + key: ValueKey(account.accountId), + onActive: _onSessionActive, + child: _buildApp(settings), + ); + }, ); }, ), ); + + Widget _buildApp(Settings settings) { + final devToolsSettings = settings.devToolsSettings; + return MaterialApp( + showPerformanceOverlay: devToolsSettings.showPerformanceOverlay, + checkerboardOffscreenLayers: devToolsSettings.checkerboardOffscreenLayers, + checkerboardRasterCacheImages: + devToolsSettings.checkerboardRasterCacheImages, + debugShowCheckedModeBanner: false, + navigatorKey: AppRoutes.rootNavigatorKey, + // Used by ChatView.didPopNext to reclaim the global ChatBloc. + // DownloadRouteObserver tracks full-page navigations so the downloads + // chip only surfaces once the user leaves the screen they started on. + navigatorObservers: [ + AppRoutes.chatRouteObserver, + DownloadRouteObserver(), + ], + localizationsDelegates: const [ + ...GlobalMaterialLocalizations.delegates, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: const [Locale('de'), Locale('en')], + locale: const Locale('de'), + title: 'Marianum Fulda', + themeMode: settings.appTheme, + theme: LightAppTheme.theme, + darkTheme: DarkAppTheme.theme, + // Brand-colored backdrop behind every route. During the logout + // home-swap and route pop animations the framework can briefly + // expose the layer below the topmost Scaffold; without this + // the dark Material default shows through and the user sees a + // black flash. + builder: (context, child) => ColoredBox( + color: LightAppTheme.marianumRed, + // Downloads tray mounted ABOVE the navigator so its chip floats over + // every route (folder views, chat, viewer are full-page pushes that + // would otherwise cover it). + child: DownloadTrayHost(child: child ?? const SizedBox.shrink()), + ), + home: EmergencyNoticeGate( + child: LoaderOverlay( + child: Breaker( + breaker: BreakerArea.global, + child: BlocBuilder( + buildWhen: (previous, current) => + previous.status != current.status, + builder: (context, accountState) { + switch (accountState.status) { + case AccountStatus.loggedIn: + return Stack( + fit: StackFit.expand, + children: [ + if (_appMounted) const App(key: ValueKey('app-shell')), + if (_showPostLoginSplash) + PostLoginSplash( + key: const ValueKey('post-login-splash'), + onComplete: () { + setState(() => _showPostLoginSplash = false); + _promptGuardianNotifications(); + }, + ), + ], + ); + case AccountStatus.loggedOut: + return const Login(); + case AccountStatus.addingAccount: + return const Login(addingAccount: true); + case AccountStatus.undefined: + return const AccountLoadingScreen(); + } + }, + ), + ), + ), + ), + ); + } } -Future _wipeUserState({ - required SettingsCubit settingsCubit, - required ChildSelectionCubit childSelectionCubit, - required ChatListBloc chatListBloc, - required ParentLettersBloc parentLettersBloc, - required ChatBloc chatBloc, - required BreakerBloc breakerBloc, - required CapabilitiesCubit capabilitiesCubit, - required NextcloudCapabilitiesCubit nextcloudCapabilitiesCubit, -}) async { +/// Wipes what outlives accounts once the last one signed out. Account data +/// itself is removed by [SessionLifecycle.signOut]. +Future _wipeDeviceState() async { try { - // Reset user-data blocs whose tree is no longer mounted after the - // home swap. We do NOT touch SettingsCubit here — its outer BlocBuilder - // wraps MaterialApp, so emit'ing a fresh state would tear down the - // freshly-mounted Login tree and leave the user with a blank screen - // (the MaterialApp.builder backdrop) until the next interaction. - // The timetable bloc is not reset here: PrimaryTimetableScope replaces it - // on the status change, and HydratedBloc.storage.clear() below drops the - // cached plans of every subject. - childSelectionCubit.reset(); - capabilitiesCubit.reset(); - nextcloudCapabilitiesCubit.reset(); - await Future.wait([ - chatListBloc.reset(), - parentLettersBloc.reset(), - chatBloc.reset(), - breakerBloc.reset(), - ]); final prefs = await SharedPreferences.getInstance(); await prefs.clear(); - await HydratedBloc.storage.clear(); - await const CacheView().clear(); // The chat background image lives outside HydratedStorage, so clear it too // (best-effort) to avoid orphaning the previous user's wallpaper. final backgroundImage = File(AppPaths.chatBackgroundImage); @@ -597,6 +595,6 @@ Future _wipeUserState({ await WidgetSync.clear(); await WidgetSync.triggerUpdate(); } catch (e, s) { - log('User state wipe failed: $e', stackTrace: s); + log('Device state wipe failed: $e', stackTrace: s); } } diff --git a/lib/push/push_registration.dart b/lib/push/push_registration.dart index 477352c..65714d5 100644 --- a/lib/push/push_registration.dart +++ b/lib/push/push_registration.dart @@ -433,6 +433,14 @@ class PushRegistration { /// first, then the proxy) with the new token. Future onTokenRefresh() => register(); + /// Stops pushes for the active account before another one takes over. The + /// app passwords stay valid so switching back can re-register silently. + Future deactivate() async { + if (DemoMode.active) return; + if (_nextcloudOrNull == null) return _direct.unregister(); + await unregister(); + } + /// Full teardown for logout: unregister push, revoke BOTH app passwords /// (each authenticated with itself — the endpoint revokes the credential it /// is called with), then clear them locally. Ordered so the proxy stops diff --git a/lib/routing/app_routes.dart b/lib/routing/app_routes.dart index 62f2746..d236f61 100644 --- a/lib/routing/app_routes.dart +++ b/lib/routing/app_routes.dart @@ -62,7 +62,9 @@ class AppRoutes { /// Root navigator key, set on [MaterialApp]. Lets globally-mounted UI (e.g. /// the downloads tray, which lives above the navigator in `MaterialApp.builder` /// and is therefore never covered by a pushed route) open full-page routes. - static final GlobalKey rootNavigatorKey = + /// Replaced per account (see `Main`), so a switch starts on a fresh + /// navigator. + static GlobalKey rootNavigatorKey = GlobalKey(); /// A context that is a descendant of the root navigator (its overlay), safe to diff --git a/lib/session/account_codec.dart b/lib/session/account_codec.dart new file mode 100644 index 0000000..ce247e2 --- /dev/null +++ b/lib/session/account_codec.dart @@ -0,0 +1,221 @@ +import 'dart:convert'; + +import 'session.dart'; +import 'session_codec.dart'; + +/// One signed-in account on this device. The session itself lives in the +/// keychain (active slots or the account's vault); this is the index entry. +class AccountEntry { + final String id; + final String kind; + + /// Username or e-mail — the identity of the account. + final String label; + + /// Real name as known to the server; null until it was loaded once. + final String? displayName; + final bool isDemo; + + /// Storage namespace of the account's local data. Empty for the account that + /// existed before multi-account support, so its data stays where it was. + final String namespace; + final int lastUsed; + + const AccountEntry({ + required this.id, + required this.kind, + required this.label, + required this.namespace, + this.displayName, + this.isDemo = false, + this.lastUsed = 0, + }); + + bool get isGuardian => kind == SessionKeys.kindGuardian; + + AccountEntry copyWith({int? lastUsed, String? displayName}) => AccountEntry( + id: id, + kind: kind, + label: label, + namespace: namespace, + displayName: displayName ?? this.displayName, + isDemo: isDemo, + lastUsed: lastUsed ?? this.lastUsed, + ); + + Map toJson() => { + 'id': id, + 'kind': kind, + 'label': label, + 'namespace': namespace, + 'displayName': displayName, + 'isDemo': isDemo, + 'lastUsed': lastUsed, + }; + + static AccountEntry? fromJson(Object? json) { + if (json is! Map) return null; + final id = json['id']; + final kind = json['kind']; + final label = json['label']; + if (id is! String || kind is! String || label is! String) return null; + return AccountEntry( + id: id, + kind: kind, + label: label, + namespace: json['namespace'] is String ? json['namespace'] as String : id, + displayName: json['displayName'] is String + ? json['displayName'] as String + : null, + isDemo: json['isDemo'] == true, + lastUsed: json['lastUsed'] is int ? json['lastUsed'] as int : 0, + ); + } +} + +class AccountIndex { + final List accounts; + final String? activeId; + + const AccountIndex({this.accounts = const [], this.activeId}); + + static const empty = AccountIndex(); + + AccountEntry? get active => byId(activeId); + + AccountEntry? byId(String? id) { + if (id == null) return null; + for (final entry in accounts) { + if (entry.id == id) return entry; + } + return null; + } + + /// The entry [session] belongs to — same kind, identity and demo flag. + AccountEntry? matching(Session session) { + final (kind, label) = identityOf(session); + for (final entry in accounts) { + if (entry.kind == kind && + entry.label == label && + entry.isDemo == session.isDemo) { + return entry; + } + } + return null; + } + + /// Makes [session] the active account, reusing its entry when it is already + /// known and otherwise adding one with [newId] (also its namespace). + AccountIndex activate( + Session session, { + required String newId, + String? namespace, + int now = 0, + }) { + final existing = matching(session); + if (existing != null) return select(existing.id, now: now); + final (kind, label) = identityOf(session); + return AccountIndex( + accounts: [ + ...accounts, + AccountEntry( + id: newId, + kind: kind, + label: label, + namespace: namespace ?? newId, + isDemo: session.isDemo, + lastUsed: now, + ), + ], + activeId: newId, + ); + } + + AccountIndex select(String id, {int now = 0}) => AccountIndex( + accounts: accounts + .map((e) => e.id == id ? e.copyWith(lastUsed: now) : e) + .toList(), + activeId: id, + ); + + AccountIndex rename(String id, String displayName) => AccountIndex( + accounts: accounts + .map((e) => e.id == id ? e.copyWith(displayName: displayName) : e) + .toList(), + activeId: activeId, + ); + + /// Drops [id]; the active account is left unset when it was the one removed. + AccountIndex remove(String id) => AccountIndex( + accounts: [ + for (final e in accounts) + if (e.id != id) e, + ], + activeId: activeId == id ? null : activeId, + ); + + /// Most recently used account other than [excludedId], if any. + AccountEntry? mostRecentExcept(String? excludedId) { + AccountEntry? best; + for (final entry in accounts) { + if (entry.id == excludedId) continue; + if (best == null || entry.lastUsed > best.lastUsed) best = entry; + } + return best; + } + + String encode() => jsonEncode({ + 'activeId': activeId, + 'accounts': [for (final e in accounts) e.toJson()], + }); + + static AccountIndex decode(String? raw) { + if (raw == null || raw.isEmpty) return empty; + try { + final json = jsonDecode(raw); + if (json is! Map) return empty; + final list = json['accounts']; + return AccountIndex( + accounts: [ + if (list is List) + for (final item in list) ?AccountEntry.fromJson(item), + ], + activeId: json['activeId'] is String + ? json['activeId'] as String + : null, + ); + } on FormatException { + return empty; + } + } +} + +(String kind, String label) identityOf(Session session) => switch (session) { + CredentialSession(:final username) => (SessionKeys.kindCredential, username), + GuardianSession(:final email) => (SessionKeys.kindGuardian, email), +}; + +/// Keychain fields of [session] including the app passwords, as stored in an +/// inactive account's vault. Unset fields are omitted. +Map sessionVaultFields(Session session) => { + for (final MapEntry(:key, :value) in encodeSessionFields(session).entries) + key: ?value, + SessionKeys.appPassword: ?session.nextcloud?.appPassword, + SessionKeys.appPasswordTalk: ?session.nextcloud?.appPasswordTalk, +}; + +String encodeVault(Map fields) => jsonEncode(fields); + +Map decodeVault(String? raw) { + if (raw == null || raw.isEmpty) return const {}; + try { + final json = jsonDecode(raw); + if (json is! Map) return const {}; + return { + for (final MapEntry(:key, :value) in json.entries) + if (key is String && value is String) key: value, + }; + } on FormatException { + return const {}; + } +} diff --git a/lib/session/session_lifecycle.dart b/lib/session/session_lifecycle.dart index 33afa98..75e57da 100644 --- a/lib/session/session_lifecycle.dart +++ b/lib/session/session_lifecycle.dart @@ -1,23 +1,38 @@ +import 'dart:async'; import 'dart:developer'; import 'package:flutter/foundation.dart'; +import '../api/marianumcloud/app_password/delete_app_password.dart'; +import '../api/marianumconnect/auth/auth_interceptor.dart'; +import '../api/marianumconnect/auth/token_storage.dart'; import '../api/marianumconnect/queries/auth_logout/auth_logout.dart'; +import '../api/marianumconnect/queries/auth_me/auth_me.dart'; import '../auth_link/guardian_link_listener.dart'; +import '../background/widget_background_task.dart'; +import '../push/chat_thread_store.dart'; +import '../push/nid_store.dart'; import '../push/push_registration.dart'; +import '../storage/account_storage.dart'; +import '../widget_data/widget_sync.dart'; +import 'session.dart'; import 'session_manager.dart'; abstract final class SessionLifecycle { /// Why the last sign-out happened, when the user did not ask for it. The - /// login screen shows it once and clears it — without it an expired session - /// just drops the user on the login screen with no explanation. + /// login screen (or, when another account takes over, the app) shows it + /// once and clears it. static final ValueNotifier signOutNotice = ValueNotifier(null); + /// Account to return to while "add account" runs; null otherwise. + static String? _addReturnId; + /// Ordered teardown: unregister push and revoke the Nextcloud app passwords /// (while those credentials still exist), then revoke the MC bearer token, - /// finally wipe the local session. Each step is best-effort so an offline - /// sign-out still reaches a clean local state. - static Future signOut({String? notice}) async { + /// finally wipe the local session and its data. Each step is best-effort so + /// an offline sign-out still reaches a clean local state. Another signed-in + /// account takes over when there is one; returns its id. + static Future signOut({String? notice}) async { signOutNotice.value = notice; try { await PushRegistration().logoutCleanup(); @@ -25,9 +40,165 @@ abstract final class SessionLifecycle { log('Sign-out: push cleanup failed: $e'); } await AuthLogout().run(); - await SessionManager().signOut(); + final removed = await SessionManager().signOut(); // A login link that arrived while signed in must not be replayed on the // login screen that follows. GuardianLinkListener.clear(); + await _clearPushStores(); + if (removed != null) await AccountStorage.delete(removed.namespace); + + for ( + var next = SessionManager().accounts.value.mostRecentExcept(null); + next != null; + next = SessionManager().accounts.value.mostRecentExcept(null) + ) { + try { + await SessionManager().activate(next.id); + break; + } on Object catch (e) { + log('Sign-out: taking over ${next.id} failed: $e'); + await SessionManager().forget(next.id); + } + } + await AccountStorage.activate(SessionManager().activeAccount); + if (SessionManager().isSignedIn) await _resetWidget(); + return SessionManager().activeAccount?.id; + } + + /// Makes the stored account [id] the active one. Push moves along: the + /// previous account is unregistered here, the new one registers once the + /// app has remounted for it. + static Future switchTo(String id) async { + final previous = SessionManager().activeAccount; + if (previous?.id == id) return; + await MarianumConnectAuthInterceptor.idle(); + try { + await PushRegistration().deactivate(); + } on Object catch (e) { + log('Switch: push deactivation failed: $e'); + } + await SessionManager().stashActive(); + try { + await SessionManager().activate(id); + } on Object { + if (previous != null) await SessionManager().activate(previous.id); + rethrow; + } finally { + await _clearPushStores(); + await AccountStorage.activate(SessionManager().activeAccount); + } + await _resetWidget(); + } + + /// Parks the active account before the login screen signs in another one. + static Future beginAddAccount() async { + await SessionManager().stashActive(); + _addReturnId = SessionManager().activeAccount?.id; + } + + static bool get isAddingAccount => _addReturnId != null; + + /// Leaves "add account" without a new account: a half-finished sign-in is + /// dropped and the previous account restored. + static Future cancelAddAccount() async { + final returnId = _addReturnId; + _addReturnId = null; + if (returnId == null) return; + if (SessionManager().activeAccount?.id != returnId && + SessionManager().isSignedIn) { + await SessionManager().signOut(); + } + await SessionManager().activate(returnId); + } + + /// Called once a login finished. After "add account" the previous account's + /// push is unregistered with its own credentials before the new one takes + /// over. Returns the id of the now active account. + static Future finishLogin() async { + final returnId = _addReturnId; + _addReturnId = null; + final added = SessionManager().activeAccount!; + if (returnId != null && returnId != added.id) { + await SessionManager().stashActive(); + await SessionManager().activate(returnId); + try { + await PushRegistration().deactivate(); + } on Object catch (e) { + log('Add account: push deactivation failed: $e'); + } + await SessionManager().activate(added.id); + await _clearPushStores(); + } + await AccountStorage.activate(SessionManager().activeAccount); + return added.id; + } + + /// Refreshes the stored real name of the active account (best-effort). + static Future refreshDisplayName() async { + if (SessionManager().current case final session? when !session.isDemo) { + try { + final user = await AuthMe().user(); + final name = '${user.firstName} ${user.lastName}'.trim(); + if (name.isNotEmpty) await SessionManager().setDisplayName(name); + } on Object catch (e) { + log('Display name refresh failed: $e'); + } + } + } + + /// Signs out an account that is not active: revokes its tokens with the + /// stored credentials (best-effort) and deletes its local data. Its push + /// was already unregistered when it stopped being active. + static Future removeInactive(String id) async { + final entry = SessionManager().accounts.value.byId(id); + if (entry == null || entry.id == SessionManager().activeAccount?.id) return; + final (session, fields) = await SessionManager().readVault(id); + if (session != null && !session.isDemo) { + await _revoke(session, fields[MarianumConnectTokenStorage.bearerKey]); + } + await SessionManager().forget(id); + await AccountStorage.delete(entry.namespace); + } + + static Future _revoke(Session session, String? token) async { + if (token != null && token.isNotEmpty) await AuthLogout.revoke(token); + final nextcloud = session.nextcloud; + if (nextcloud == null) return; + try { + if (nextcloud.hasAppPassword) { + await DeleteAppPassword().run( + authorizationHeader: nextcloud.basicAuthHeader, + ); + } + if (nextcloud.hasAppPasswordTalk) { + await DeleteAppPassword().run( + authorizationHeader: nextcloud.talkBasicAuthHeader, + ); + } + } on Object catch (e) { + log('Remove account: app password revoke failed: $e'); + } + } + + /// The widget still shows the previous account's plan; the app republishes + /// once it mounted, the refresh covers a backgrounded app. + static Future _resetWidget() async { + try { + await WidgetSync.clear(); + await WidgetSync.triggerUpdate(); + unawaited(WidgetBackgroundTask.requestImmediateRefresh()); + } on Object catch (e) { + log('Widget reset failed: $e'); + } + } + + // Tray bookkeeping of the previous account's pushes. + static Future _clearPushStores() async { + try { + await NidStore().clear(); + await ChatThreadStore().clearAll(); + } on Object catch (e) { + log('Push store cleanup failed: $e'); + } } } diff --git a/lib/session/session_manager.dart b/lib/session/session_manager.dart index a8a2bdc..ee4c17f 100644 --- a/lib/session/session_manager.dart +++ b/lib/session/session_manager.dart @@ -6,14 +6,22 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../api/marianumconnect/auth/token_storage.dart'; import '../push/push_secure_storage.dart'; import '../utils/exponential_backoff.dart'; +import '../utils/random_id.dart'; +import 'account_codec.dart'; import 'nextcloud_credentials.dart'; import 'session.dart'; import 'session_codec.dart'; /// Owns the active [Session] and its persistence. One instance per isolate; /// the widget background isolate reads the same keychain. +/// +/// Several accounts can be signed in; the frozen [SessionKeys] slots, the MC +/// token and the group-keychain app passwords always hold the *active* one +/// (native code and the background isolate read only those). Inactive +/// accounts are parked in a per-account vault entry. class SessionManager { // `first_unlock` so a background launch on a locked device (silent push, // BGAppRefresh) can still read the session. Items written by older versions @@ -34,6 +42,10 @@ class SessionManager { SessionKeys.loginFlow, ]; + static const _indexKey = 'accounts_index'; + static String _vaultKey(String id) => 'account_vault_$id'; + static const _tokenStorage = MarianumConnectTokenStorage(); + static final SessionManager _instance = SessionManager._(); factory SessionManager() => _instance; @@ -46,6 +58,13 @@ class SessionManager { Session? get current => _current; + /// Every signed-in account and which one is active. + final ValueNotifier accounts = ValueNotifier( + AccountIndex.empty, + ); + + AccountEntry? get activeAccount => accounts.value.active; + bool get isSignedIn => _current != null; bool get isDemo => _current?.isDemo ?? false; @@ -80,8 +99,22 @@ class SessionManager { NextcloudCredentials requireNextcloud() => _current?.nextcloud ?? (throw const NextcloudUnavailableException()); - /// Replaces any stored session completely; no prior [signOut] needed. + /// Makes [session] the active account, replacing the active slots + /// completely. An account signed in before keeps its entry; call + /// [stashActive] first so the previously active one stays switchable. Future signIn(Session session) async { + await _writeActive(session); + await _saveIndex( + accounts.value.activate( + session, + newId: randomHexId(bytes: 8), + now: _now(), + ), + ); + if (!_loaded.isCompleted) _loaded.complete(); + } + + Future _writeActive(Session session) async { await Future.wait([ for (final MapEntry(:key, :value) in encodeSessionFields(session).entries) _writeSecret(key, value), @@ -95,19 +128,78 @@ class SessionManager { ), ]); _current = session; - if (!_loaded.isCompleted) _loaded.complete(); } - Future signOut() async { + /// Wipes the active slots and forgets the active account. Other accounts + /// stay in their vaults; see [activate]. + Future signOut() async { + final removed = activeAccount; _loaded = Completer(); _current = null; await Future.wait([ for (final field in _sessionFields) _secureStorage.delete(key: field), _writeGroupSecret(SessionKeys.appPassword, null), _writeGroupSecret(SessionKeys.appPasswordTalk, null), + if (removed != null) _secureStorage.delete(key: _vaultKey(removed.id)), ]); + if (removed != null) await _saveIndex(accounts.value.remove(removed.id)); + return removed; } + /// Parks the active account (session, app passwords, MC token) in its vault + /// so the slots can be taken over by another account. + Future stashActive() async { + final session = _current; + final entry = activeAccount; + if (session == null || entry == null) return; + final fields = { + ...sessionVaultFields(session), + ...await _tokenStorage.readAll(), + }; + await _secureStorage.write( + key: _vaultKey(entry.id), + value: encodeVault(fields), + ); + } + + /// Loads the vault of [id] into the active slots. The active account must + /// have been stashed before, or its session is lost. + Future activate(String id) async { + final fields = decodeVault(await _secureStorage.read(key: _vaultKey(id))); + final session = decodeSession(fields); + if (session == null) throw StateError('No stored session for account $id'); + await _writeActive(session); + await _tokenStorage.writeAll(fields); + await _saveIndex(accounts.value.select(id, now: _now())); + if (!_loaded.isCompleted) _loaded.complete(); + } + + /// Remembers the real name of the active account for the account list. + Future setDisplayName(String displayName) async { + final entry = activeAccount; + if (entry == null || entry.displayName == displayName) return; + await _saveIndex(accounts.value.rename(entry.id, displayName)); + } + + /// Session and MC token of an inactive account, e.g. to revoke them. + Future<(Session?, Map)> readVault(String id) async { + final fields = decodeVault(await _secureStorage.read(key: _vaultKey(id))); + return (decodeSession(fields), fields); + } + + /// Drops an inactive account without touching the active slots. + Future forget(String id) async { + await _secureStorage.delete(key: _vaultKey(id)); + await _saveIndex(accounts.value.remove(id)); + } + + Future _saveIndex(AccountIndex index) async { + accounts.value = index; + await _secureStorage.write(key: _indexKey, value: index.encode()); + } + + static int _now() => DateTime.now().millisecondsSinceEpoch; + /// Persists a freshly minted Nextcloud app password; from then on every /// Nextcloud call authenticates with it instead of the real password. Future setAppPassword(String appPassword) async { @@ -205,9 +297,36 @@ class SessionManager { // Group keystore unavailable: fall back to the real password. } _current = decodeSession(raw); + await _loadIndex(); if (!_loaded.isCompleted) _loaded.complete(); } + Future _loadIndex() async { + var index = AccountIndex.decode(await _secureStorage.read(key: _indexKey)); + final session = _current; + if (session != null) { + if (index.active == null || + index.matching(session)?.id != index.activeId) { + // First start after the update: the existing account keeps its data + // in the un-namespaced storage. + index = index.activate( + session, + newId: randomHexId(bytes: 8), + namespace: index.accounts.isEmpty ? '' : null, + now: _now(), + ); + await _secureStorage.write(key: _indexKey, value: index.encode()); + } + accounts.value = index; + return; + } + accounts.value = index.remove(index.activeId ?? ''); + // Interrupted switch or sign-out: fall back to another stored account + // instead of showing the login screen. + final fallback = accounts.value.mostRecentExcept(null); + if (fallback != null) await activate(fallback.id); + } + // Move credentials from the old SharedPreferences plain-text storage into the // platform's secure keystore. Run once per install and clear the legacy keys. Future _migrateFromLegacyStorage() async { diff --git a/lib/share_intent/share_intent_listener.dart b/lib/share_intent/share_intent_listener.dart index 27b429d..eaa57ab 100644 --- a/lib/share_intent/share_intent_listener.dart +++ b/lib/share_intent/share_intent_listener.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:receive_sharing_intent/receive_sharing_intent.dart'; +import '../auth_link/guardian_login_link.dart'; import 'pending_share.dart'; /// Bridges native share intents (Android ACTION_SEND, iOS Share Extension) @@ -39,8 +40,7 @@ class ShareIntentListener { final share = _toPendingShare(items); if (share != null) _publish(share); }, - onError: (Object e) => - debugPrint('ShareIntentListener stream error: $e'), + onError: (Object e) => debugPrint('ShareIntentListener stream error: $e'), ); } @@ -87,6 +87,16 @@ class ShareIntentListener { unawaited(ReceiveSharingIntent.instance.reset()); } + /// Android hands an opened App Link (guardian login mail) to the share + /// plugin as a shared URL too; it belongs to [GuardianLinkListener] only. + @visibleForTesting + static bool isAppLink(String value) { + final uri = Uri.tryParse(value.trim()); + return uri != null && + uri.hasScheme && + uri.path.endsWith(GuardianLoginLink.path); + } + PendingShare? _toPendingShare(List items) { if (items.isEmpty) return null; final files = []; @@ -99,7 +109,7 @@ class ShareIntentListener { files.add(item.path); case SharedMediaType.text: case SharedMediaType.url: - texts.add(item.path); + if (!isAppLink(item.path)) texts.add(item.path); } } if (files.isEmpty && texts.isEmpty) return null; diff --git a/lib/state/app/modules/account/account_scope.dart b/lib/state/app/modules/account/account_scope.dart new file mode 100644 index 0000000..f9ff676 --- /dev/null +++ b/lib/state/app/modules/account/account_scope.dart @@ -0,0 +1,71 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../breaker/bloc/breaker_bloc.dart'; +import '../capabilities/bloc/capabilities_cubit.dart'; +import '../chat/bloc/chat_bloc.dart'; +import '../chat_list/bloc/chat_list_bloc.dart'; +import '../children/child_selection_cubit.dart'; +import '../nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart'; +import '../parent_letters/bloc/parent_letters_bloc.dart'; +import '../timetable/primary/primary_timetable_scope.dart'; +import 'bloc/account_bloc.dart'; +import 'bloc/account_state.dart'; + +/// The blocs of one account. Keyed by the account id above, so a switch +/// recreates all of them from the new account's storage instead of resetting +/// them one by one. +class AccountScope extends StatelessWidget { + /// Called with a context below the account's blocs once the scope mounted + /// for a signed-in account. + final void Function(BuildContext scopeContext) onActive; + final Widget child; + + const AccountScope({super.key, required this.onActive, required this.child}); + + @override + Widget build(BuildContext context) => MultiBlocProvider( + providers: [ + BlocProvider(create: (_) => BreakerBloc()), + BlocProvider(create: (_) => CapabilitiesCubit()), + BlocProvider( + create: (_) => NextcloudCapabilitiesCubit(), + ), + BlocProvider(create: (_) => ChatListBloc()), + BlocProvider( + create: (ctx) => ChatBloc(chatListBloc: ctx.read()), + ), + BlocProvider(create: (_) => ChildSelectionCubit()), + BlocProvider(create: (_) => ParentLettersBloc()), + ], + child: PrimaryTimetableScope( + child: _Activation(onActive: onActive, child: child), + ), + ); +} + +class _Activation extends StatefulWidget { + final void Function(BuildContext scopeContext) onActive; + final Widget child; + + const _Activation({required this.onActive, required this.child}); + + @override + State<_Activation> createState() => _ActivationState(); +} + +class _ActivationState extends State<_Activation> { + @override + void initState() { + super.initState(); + if (context.read().state.status != AccountStatus.loggedIn) { + return; + } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) widget.onActive(context); + }); + } + + @override + Widget build(BuildContext context) => widget.child; +} diff --git a/lib/state/app/modules/account/bloc/account_bloc.dart b/lib/state/app/modules/account/bloc/account_bloc.dart index f1d2b6a..c02a24a 100644 --- a/lib/state/app/modules/account/bloc/account_bloc.dart +++ b/lib/state/app/modules/account/bloc/account_bloc.dart @@ -4,12 +4,38 @@ import 'account_event.dart'; import 'account_state.dart'; class AccountBloc extends Bloc { - AccountBloc({AccountStatus initialStatus = AccountStatus.undefined}) - : super(AccountState(status: initialStatus)) { + AccountBloc({ + AccountStatus initialStatus = AccountStatus.undefined, + String? accountId, + }) : super(AccountState(status: initialStatus, accountId: accountId)) { on( - (event, emit) => emit(state.copyWith(status: event.status)), + (event, emit) => emit( + AccountState( + status: event.status, + accountId: event.status == AccountStatus.loggedOut + ? null + : state.accountId, + ), + ), + ); + on( + (event, emit) => emit( + AccountState( + status: AccountStatus.loggedIn, + accountId: event.accountId, + freshLogin: event.freshLogin, + ), + ), ); } void setStatus(AccountStatus status) => add(AccountStatusChanged(status)); + + /// [accountId] became the active account (login, switch or takeover after a + /// sign-out); null means no account is left. + void activated(String? accountId, {bool freshLogin = false}) => add( + accountId == null + ? const AccountStatusChanged(AccountStatus.loggedOut) + : AccountActivated(accountId, freshLogin: freshLogin), + ); } diff --git a/lib/state/app/modules/account/bloc/account_event.dart b/lib/state/app/modules/account/bloc/account_event.dart index a3a0f8a..b740e8a 100644 --- a/lib/state/app/modules/account/bloc/account_event.dart +++ b/lib/state/app/modules/account/bloc/account_event.dart @@ -8,3 +8,9 @@ class AccountStatusChanged extends AccountEvent { final AccountStatus status; const AccountStatusChanged(this.status); } + +class AccountActivated extends AccountEvent { + final String accountId; + final bool freshLogin; + const AccountActivated(this.accountId, {required this.freshLogin}); +} diff --git a/lib/state/app/modules/account/bloc/account_state.dart b/lib/state/app/modules/account/bloc/account_state.dart index 878407d..aa4f2c2 100644 --- a/lib/state/app/modules/account/bloc/account_state.dart +++ b/lib/state/app/modules/account/bloc/account_state.dart @@ -1,9 +1,18 @@ -enum AccountStatus { undefined, loggedIn, loggedOut } +enum AccountStatus { undefined, loggedIn, loggedOut, addingAccount } class AccountState { final AccountStatus status; - const AccountState({this.status = AccountStatus.undefined}); - AccountState copyWith({AccountStatus? status}) => - AccountState(status: status ?? this.status); + /// Active account; the account-scoped part of the app is keyed by it. + final String? accountId; + + /// Set when [accountId] came from a login (not a switch), to show the + /// post-login splash. + final bool freshLogin; + + const AccountState({ + this.status = AccountStatus.undefined, + this.accountId, + this.freshLogin = false, + }); } diff --git a/lib/state/app/modules/settings/bloc/settings_cubit.dart b/lib/state/app/modules/settings/bloc/settings_cubit.dart index 7ba12ed..bc56955 100644 --- a/lib/state/app/modules/settings/bloc/settings_cubit.dart +++ b/lib/state/app/modules/settings/bloc/settings_cubit.dart @@ -11,7 +11,7 @@ class SettingsCubit extends HydratedCubit { static const _debounceTag = 'settings_persist'; bool _emitScheduled = false; - SettingsCubit() : super(DefaultSettings.get()); + SettingsCubit({super.storage}) : super(DefaultSettings.get()); Settings val({bool write = false}) { if (write) { diff --git a/lib/state/app/modules/timetable/primary/primary_timetable_scope.dart b/lib/state/app/modules/timetable/primary/primary_timetable_scope.dart index a508a82..cd31e62 100644 --- a/lib/state/app/modules/timetable/primary/primary_timetable_scope.dart +++ b/lib/state/app/modules/timetable/primary/primary_timetable_scope.dart @@ -64,7 +64,10 @@ class _PrimaryTimetableScopeState extends State { Widget build(BuildContext context) => MultiBlocListener( listeners: [ BlocListener( - listenWhen: (a, b) => a.status != b.status, + // While another account is being added, the session may already + // belong to it; this scope is replaced once that account takes over. + listenWhen: (a, b) => + a.status != b.status && b.status != AccountStatus.addingAccount, listener: (_, _) => _sync(), ), BlocListener( @@ -73,9 +76,6 @@ class _PrimaryTimetableScopeState extends State { ), BlocListener(listener: (_, _) => _sync()), ], - child: BlocProvider.value( - value: _bloc, - child: widget.child, - ), + child: BlocProvider.value(value: _bloc, child: widget.child), ); } diff --git a/lib/storage/account_storage.dart b/lib/storage/account_storage.dart new file mode 100644 index 0000000..7f54460 --- /dev/null +++ b/lib/storage/account_storage.dart @@ -0,0 +1,87 @@ +import 'dart:developer'; +import 'dart:io'; + +import 'package:hydrated_bloc/hydrated_bloc.dart'; +import 'package:localstore/localstore.dart'; + +import '../api/request_cache.dart'; +import '../session/account_codec.dart'; +import 'hydrated_storage_bootstrap.dart'; + +/// Per-account local data: every account gets its own HydratedBloc box and +/// Localstore cache collection, so switching back is instant and offline. +/// Only the app settings live in the shared [global] box. +abstract final class AccountStorage { + static const _legacyCollection = 'MarianumMobile'; + static const _settingsKey = 'SettingsCubit'; + + static late String _baseDir; + static final Map _open = {}; + static Storage _global = InMemoryStorage(); + + static Storage get global => _global; + + /// Opens the global box. Settings of installs from before multi-account + /// support are copied over from the legacy box once. + static Future init(String baseDir) async { + _baseDir = baseDir; + _global = await buildHydratedStorageWithFallback( + _ensureDir('$baseDir/hydrated_global'), + ); + HydratedBloc.storage = InMemoryStorage(); + if (_global.read(_settingsKey) != null) return; + final legacySettings = (await _storageFor('')).read(_settingsKey); + if (legacySettings != null) { + await _global.write(_settingsKey, legacySettings); + } + } + + /// Points HydratedBloc and the request cache at [account]'s data. Blocs + /// keep the storage they were created with, so the per-account blocs have + /// to be recreated afterwards. + static Future activate(AccountEntry? account) async { + if (account == null) { + HydratedBloc.storage = InMemoryStorage(); + RequestCache.collection = _legacyCollection; + return; + } + HydratedBloc.storage = await _storageFor(account.namespace); + RequestCache.collection = cacheCollection(account.namespace); + } + + /// Removes all local data of the account with [namespace]. + static Future delete(String namespace) async { + try { + final storage = await _storageFor(namespace); + await storage.clear(); + // Closed boxes ignore writes, so blocs of the account that are still + // mounted until the remount cannot leave data behind. + await storage.close(); + _open.remove(namespace); + if (namespace.isNotEmpty) { + final dir = Directory(_dirFor(namespace)); + if (dir.existsSync()) await dir.delete(recursive: true); + } + await Localstore.instance.collection(cacheCollection(namespace)).delete(); + } on Object catch (e, s) { + log('Account storage delete failed: $e', stackTrace: s); + } + } + + static String cacheCollection(String namespace) => + namespace.isEmpty ? _legacyCollection : '$_legacyCollection-$namespace'; + + static Future _storageFor(String namespace) async => + _open[namespace] ??= await buildHydratedStorageWithFallback( + _ensureDir(_dirFor(namespace)), + ); + + // The legacy account keeps the box at the root of the base directory. + static String _dirFor(String namespace) => + namespace.isEmpty ? _baseDir : '$_baseDir/accounts/$namespace'; + + static String _ensureDir(String path) { + Directory(path).createSync(recursive: true); + return path; + } +} diff --git a/lib/view/login/login.dart b/lib/view/login/login.dart index e5e4e38..2ca1f8d 100644 --- a/lib/view/login/login.dart +++ b/lib/view/login/login.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:developer'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -8,8 +9,8 @@ import '../../auth_link/guardian_link_listener.dart'; import '../../auth_link/guardian_login_link.dart'; import '../../background/widget_background_task.dart'; import '../../session/session_lifecycle.dart'; +import '../../session/session_manager.dart'; import '../../state/app/modules/account/bloc/account_bloc.dart'; -import '../../state/app/modules/account/bloc/account_state.dart'; import '../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../storage/dev_tools_settings.dart'; import '../../storage/settings.dart' as model; @@ -26,7 +27,10 @@ import 'widgets/login_branding.dart'; import 'widgets/login_card.dart'; class Login extends StatefulWidget { - const Login({super.key}); + /// Signs in another account while one is active; offers to go back. + final bool addingAccount; + + const Login({super.key, this.addingAccount = false}); @override State createState() => _LoginState(); @@ -85,7 +89,7 @@ class _LoginState extends State with SingleTickerProviderStateMixin { return; } final signedIn = await _guardianController.submitLink(link); - if (signedIn && mounted) _onLoginSuccess(); + if (signedIn && mounted) await _onLoginSuccess(); } @override @@ -104,95 +108,141 @@ class _LoginState extends State with SingleTickerProviderStateMixin { super.dispose(); } - void _onLoginSuccess() { + Future _onLoginSuccess() async { Haptics.heavyAccent(); + final accountBloc = context.read(); + // Fade the login content out before handing over to the post-login splash. + // Both share the red backdrop, so this reads as one continuous transition + // instead of an abrupt swap. + final finished = SessionLifecycle.finishLogin(); + await _fade.reverse().orCancel.onError((_, _) {}); + String? accountId; + try { + accountId = await finished; + } on Object catch (e) { + log('Login: finishing failed: $e'); + accountId = SessionManager().activeAccount?.id; + } // Re-register the periodic refresh (cancelAll runs on logout) and kick // off an immediate one-off so the widget populates within seconds // instead of waiting up to 30 minutes for the next periodic slot. unawaited(WidgetBackgroundTask.initialize()); unawaited(WidgetBackgroundTask.requestImmediateRefresh()); - // Fade the login content out before handing over to the post-login splash. - // Both share the red backdrop, so this reads as one continuous transition - // instead of an abrupt swap. - _fade.reverse().whenComplete(() { - if (!mounted) return; - context.read().setStatus(AccountStatus.loggedIn); - }); + accountBloc.activated(accountId, freshLogin: true); + } + + Future _cancelAddAccount() async { + final accountBloc = context.read(); + await SessionLifecycle.cancelAddAccount(); + accountBloc.activated(SessionManager().activeAccount?.id); } Widget _buildCard() => switch (_audience) { null => LoginAudienceCard( + addingAccount: widget.addingAccount, onSelected: (choice) => setState(() => _audience = choice), ), LoginAudience.school => LoginCard( controller: _controller, onSuccess: _onLoginSuccess, + addingAccount: widget.addingAccount, ), LoginAudience.guardian => GuardianLoginCard( controller: _guardianController, onSuccess: _onLoginSuccess, + addingAccount: widget.addingAccount, ), }; @override - Widget build(BuildContext context) => Scaffold( - backgroundColor: _marianumRed, - body: FadeTransition( - opacity: _fade, - child: SafeArea( - child: LayoutBuilder( - builder: (context, constraints) => SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Center( - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: constraints.maxHeight, - maxWidth: 420, + Widget build(BuildContext context) => PopScope( + canPop: !widget.addingAccount, + onPopInvokedWithResult: (didPop, _) { + if (!didPop && !_busy) unawaited(_cancelAddAccount()); + }, + child: Scaffold( + backgroundColor: _marianumRed, + appBar: widget.addingAccount + ? AppBar( + backgroundColor: Colors.transparent, + foregroundColor: Colors.white, + elevation: 0, + leading: ListenableBuilder( + listenable: Listenable.merge([ + _controller, + _guardianController, + ]), + builder: (context, _) => IconButton( + icon: const Icon(Icons.close), + tooltip: 'Abbrechen', + onPressed: _busy ? null : _cancelAddAccount, ), - // spaceBetween statt Spacer-in-IntrinsicHeight: Letzteres würde - // die Column bei Inhaltsänderungen im unteren Block auf die - // intrinsic-Höhe pinnen und ein paar Pixel Overflow erzeugen. - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - children: [ - const LoginHeader(), - const SizedBox(height: 28), - _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, + ), + title: const Text('Konto hinzufügen'), + ) + : null, + body: FadeTransition( + opacity: _fade, + child: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Center( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + maxWidth: 420, + ), + // spaceBetween statt Spacer-in-IntrinsicHeight: Letzteres würde + // die Column bei Inhaltsänderungen im unteren Block auf die + // intrinsic-Höhe pinnen und ein paar Pixel Overflow erzeugen. + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + children: [ + LoginHeader(addingAccount: widget.addingAccount), + const SizedBox(height: 28), + _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), ), - 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( - mainAxisSize: MainAxisSize.min, - children: [_EndpointLink(), LoginFooter()], - ), - ], + ) + else + const SizedBox(height: 12), + ], + ), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + // The new account must live on the server the app + // already talks to. + if (!widget.addingAccount) const _EndpointLink(), + const LoginFooter(), + ], + ), + ], + ), ), ), ), @@ -201,6 +251,8 @@ class _LoginState extends State with SingleTickerProviderStateMixin { ), ), ); + + bool get _busy => _controller.loading || _guardianController.loading; } /// Subtle text link above the footer that surfaces the currently selected diff --git a/lib/view/login/login_controller.dart b/lib/view/login/login_controller.dart index 0a4e167..8c71406 100644 --- a/lib/view/login/login_controller.dart +++ b/lib/view/login/login_controller.dart @@ -61,6 +61,7 @@ class LoginController extends ChangeNotifier { return LoginResult.success; } + var signedIn = false; try { await _discardPreviousAccount(); // AuthLogin = Credential-Probe + Token-Create in einem Call. @@ -73,6 +74,7 @@ class LoginController extends ChangeNotifier { await SessionManager().signIn( CredentialSession(username: user, password: password), ); + signedIn = true; // Mint the Nextcloud app password now — it doubles as the Nextcloud // credential probe: a rejection means 2FA is active (or the NC password // diverges) and the login must finish interactively in the browser. @@ -82,7 +84,9 @@ class LoginController extends ChangeNotifier { return ncReady ? LoginResult.success : LoginResult.nextcloudLoginRequired; } catch (e) { log(e.toString()); - await SessionManager().signOut(); + // Only the account signed in by this attempt; while adding an account + // the active one is parked and restored on cancel. + if (signedIn) await SessionManager().signOut(); await const MarianumConnectTokenStorage().clear(); final isWrongCredentials = e is AuthException && e.statusCode == 401; _errorMessage = isWrongCredentials @@ -98,7 +102,8 @@ 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. + /// selbst überschreibt signIn vollständig; beim Hinzufügen eines Kontos + /// liegt der Token des aktiven Kontos schon in dessen Tresor. Future _discardPreviousAccount() async { await const MarianumConnectTokenStorage().clear(); await WidgetSync.clear(); diff --git a/lib/view/login/widgets/guardian_login_card.dart b/lib/view/login/widgets/guardian_login_card.dart index 59f1d1b..b0df133 100644 --- a/lib/view/login/widgets/guardian_login_card.dart +++ b/lib/view/login/widgets/guardian_login_card.dart @@ -14,9 +14,13 @@ class GuardianLoginCard extends StatefulWidget { final GuardianLoginController controller; final VoidCallback onSuccess; + /// Signs in an additional account instead of the first one. + final bool addingAccount; + const GuardianLoginCard({ required this.controller, required this.onSuccess, + this.addingAccount = false, super.key, }); @@ -118,7 +122,9 @@ class _GuardianLoginCardState extends State { return Form( key: _emailFormKey, child: LoginCardFrame( - title: 'Anmeldung für Eltern', + title: widget.addingAccount + ? 'Elternkonto hinzufügen' + : '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.', @@ -194,7 +200,7 @@ class _GuardianLoginCardState extends State { const SizedBox(height: 20), LoginSubmitButton( key: const Key('guardian-verify-button'), - label: 'Anmelden', + label: widget.addingAccount ? 'Hinzufügen' : 'Anmelden', loading: _controller.loading, onPressed: _submitCode, ), diff --git a/lib/view/login/widgets/login_audience_card.dart b/lib/view/login/widgets/login_audience_card.dart index aaae3ba..f483eb4 100644 --- a/lib/view/login/widgets/login_audience_card.dart +++ b/lib/view/login/widgets/login_audience_card.dart @@ -9,24 +9,35 @@ enum LoginAudience { school, guardian } class LoginAudienceCard extends StatelessWidget { final ValueChanged onSelected; - const LoginAudienceCard({required this.onSelected, super.key}); + /// Signs in an additional account instead of the first one. + final bool addingAccount; + + const LoginAudienceCard({ + required this.onSelected, + this.addingAccount = false, + super.key, + }); @override Widget build(BuildContext context) => LoginCardFrame( - title: 'Anmelden', - hint: 'Bitte wähle deine Anmeldemethode', + title: addingAccount ? 'Konto hinzufügen' : 'Anmelden', + hint: addingAccount + ? 'Welches Konto möchtest du hinzufügen?' + : 'Bitte wähle deine Anmeldemethode', children: [ _AudienceButton( key: const Key('login-audience-school'), icon: Icons.school_outlined, - label: 'Login für Schülerschaft & Lehrkräfte', + label: addingAccount + ? 'Schulkonto (Schülerschaft & Lehrkräfte)' + : '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', + label: addingAccount ? 'Elternkonto' : 'Login für Eltern', onPressed: () => onSelected(LoginAudience.guardian), ), ], diff --git a/lib/view/login/widgets/login_branding.dart b/lib/view/login/widgets/login_branding.dart index 9d6bf2a..d78e949 100644 --- a/lib/view/login/widgets/login_branding.dart +++ b/lib/view/login/widgets/login_branding.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; class LoginHeader extends StatelessWidget { - const LoginHeader({super.key}); + /// Signs in an additional account instead of the first one. + final bool addingAccount; + + const LoginHeader({this.addingAccount = false, super.key}); @override Widget build(BuildContext context) => Column( @@ -29,7 +32,10 @@ class LoginHeader extends StatelessWidget { ), const SizedBox(height: 6), Text( - 'Stundenplan, Talk, Dateien und mehr - alles an einem Ort für deinen Schulalltag am Marianum Fulda.', + addingAccount + ? 'Melde ein weiteres Konto an. In den Einstellungen kannst du ' + 'danach jederzeit zwischen deinen Konten wechseln.' + : 'Stundenplan, Talk, Dateien und mehr - alles an einem Ort für deinen Schulalltag am Marianum Fulda.', textAlign: TextAlign.center, style: TextStyle( color: Colors.white.withValues(alpha: 0.85), diff --git a/lib/view/login/widgets/login_card.dart b/lib/view/login/widgets/login_card.dart index a38c152..0c330c9 100644 --- a/lib/view/login/widgets/login_card.dart +++ b/lib/view/login/widgets/login_card.dart @@ -12,9 +12,13 @@ class LoginCard extends StatefulWidget { final LoginController controller; final VoidCallback onSuccess; + /// Signs in an additional account instead of the first one. + final bool addingAccount; + const LoginCard({ required this.controller, required this.onSuccess, + this.addingAccount = false, super.key, }); @@ -83,8 +87,11 @@ class _LoginCardState extends State { return Form( key: _formKey, child: LoginCardFrame( - title: 'Anmelden', - hint: 'Melde dich mit deinen Marianum-Zugangsdaten an.', + title: widget.addingAccount ? 'Schulkonto hinzufügen' : 'Anmelden', + hint: widget.addingAccount + ? 'Melde dich mit den Marianum-Zugangsdaten des Kontos an, das ' + 'du hinzufügen möchtest.' + : 'Melde dich mit deinen Marianum-Zugangsdaten an.', children: [ TextFormField( key: const Key('login-username-field'), @@ -127,7 +134,7 @@ class _LoginCardState extends State { const SizedBox(height: 20), LoginSubmitButton( key: const Key('login-submit-button'), - label: 'Anmelden', + label: widget.addingAccount ? 'Hinzufügen' : 'Anmelden', loading: loading, onPressed: _submit, ), diff --git a/lib/view/pages/settings/sections/account_section.dart b/lib/view/pages/settings/sections/account_section.dart index 3f92dce..b6c17c8 100644 --- a/lib/view/pages/settings/sections/account_section.dart +++ b/lib/view/pages/settings/sections/account_section.dart @@ -6,24 +6,25 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../api/marianumcloud/cloud_users/cloud_users_actions.dart'; import '../../../../push/push_registration.dart'; import '../../../../routing/app_routes.dart'; +import '../../../../session/account_codec.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/account_switcher_sheet.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'; -// Display-name is process-wide stable until the user logs out; cache it so -// every Settings rebuild doesn't re-issue the OCS request. +// Display-name is stable per account; cache it so every Settings rebuild +// doesn't re-issue the OCS request. String? _cachedDisplayName; +String? _cachedDisplayNameFor; class AccountSection extends StatelessWidget { const AccountSection({super.key}); @@ -44,20 +45,45 @@ class _GuardianAccount extends StatelessWidget { @override Widget build(BuildContext context) { final children = context.watch().state.children; + final colors = Theme.of(context).colorScheme; 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), + Padding( + padding: const EdgeInsets.fromLTRB(16, 20, 8, 16), + child: Row( + children: [ + CircleAvatar( + radius: 36, + backgroundColor: colors.secondaryContainer, + foregroundColor: colors.onSecondaryContainer, + child: const Icon(Icons.family_restroom_outlined, size: 34), + ), + const SizedBox(width: 12), + Expanded( + child: ValueListenableBuilder( + valueListenable: SessionManager().accounts, + builder: (context, index, _) => _AccountName( + name: index.active?.displayName ?? 'Elternkonto', + identity: email, + ), + ), + ), + const SizedBox(width: 4), + const _AccountActions(), + ], ), ), + if (children.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Text( + children.length == 1 ? 'Dein Kind' : 'Deine Kinder', + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: Theme.of(context).colorScheme.primary, + ), + ), + ), for (final child in children) ChildTile(child: child), ], ); @@ -74,17 +100,20 @@ class _SchoolAccount extends StatefulWidget { class _SchoolAccountState extends State<_SchoolAccount> { int _avatarVersion = 0; bool _avatarBusy = false; - String? _displayName = _cachedDisplayName; + String? _displayName; @override void initState() { super.initState(); - if (_displayName == null) _loadDisplayName(); + final username = SessionManager().requireNextcloud().username; + if (_cachedDisplayNameFor == username) _displayName = _cachedDisplayName; + if (_displayName == null) _loadDisplayName(username); } - Future _loadDisplayName() async { + Future _loadDisplayName(String username) async { try { final info = await GetUserInfo().run(); + _cachedDisplayNameFor = username; _cachedDisplayName = info.displayName.isEmpty ? null : info.displayName; if (!mounted) return; setState(() => _displayName = _cachedDisplayName); @@ -143,7 +172,7 @@ class _SchoolAccountState extends State<_SchoolAccount> { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( - padding: const EdgeInsets.fromLTRB(16, 20, 16, 16), + padding: const EdgeInsets.fromLTRB(16, 20, 8, 16), child: Row( children: [ SizedBox( @@ -177,7 +206,7 @@ class _SchoolAccountState extends State<_SchoolAccount> { ], ), ), - const SizedBox(width: 16), + const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -207,12 +236,8 @@ class _SchoolAccountState extends State<_SchoolAccount> { ], ), ), - const SizedBox(width: 8), - TextButton.icon( - icon: const Icon(Icons.logout_outlined, size: 18), - label: const Text('Abmelden'), - onPressed: () => _confirmLogout(context), - ), + const SizedBox(width: 4), + const _AccountActions(), ], ), ), @@ -245,26 +270,77 @@ class _SchoolAccountState extends State<_SchoolAccount> { } Future _confirmLogout(BuildContext context) async { + final accountBloc = context.read(); + final others = SessionManager().accounts.value.accounts.length - 1; + String? nextAccountId; // Flip AccountBloc state only after the dialog fully closes: doing it from // inside the sign-out (the previous approach) raced AsyncDialogAction's - // pop(true) against the listener's popUntil(isFirst) and could leave the - // navigator in an inconsistent state. + // pop(true) against the navigator teardown of the account switch. final confirmed = await showDialog( context: context, builder: (dialogContext) => ConfirmDialog( title: 'Abmelden?', - content: 'Möchtest du dich wirklich abmelden?', + content: others > 0 + ? 'Möchtest du dich wirklich abmelden? Die App wechselt danach zu ' + 'einem deiner anderen Konten.' + : 'Möchtest du dich wirklich abmelden?', confirmButton: 'Abmelden', - onConfirmAsync: _performLogout, + onConfirmAsync: () async => + nextAccountId = await SessionLifecycle.signOut(), ), ); - if (confirmed != true || !context.mounted) return; - context.read().setStatus(AccountStatus.loggedOut); + if (confirmed != true) return; + accountBloc.activated(nextAccountId); } -Future _performLogout() async { - await SessionLifecycle.signOut(); - _cachedDisplayName = null; +class _AccountName extends StatelessWidget { + final String name; + final String identity; + + const _AccountName({required this.name, required this.identity}); + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + name, + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w600), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + identity, + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ); +} + +class _AccountActions extends StatelessWidget { + const _AccountActions(); + + @override + Widget build(BuildContext context) => Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextButton.icon( + style: compactAccountButtonStyle, + icon: const Icon(Icons.logout_outlined, size: 18), + label: const Text('Abmelden'), + onPressed: () => _confirmLogout(context), + ), + const AccountSwitchButton(), + ], + ); } class _AvatarEditBadge extends StatelessWidget { diff --git a/lib/widget/account_switcher_sheet.dart b/lib/widget/account_switcher_sheet.dart new file mode 100644 index 0000000..4d4d302 --- /dev/null +++ b/lib/widget/account_switcher_sheet.dart @@ -0,0 +1,193 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../session/account_codec.dart'; +import '../session/session_lifecycle.dart'; +import '../session/session_manager.dart'; +import '../state/app/modules/account/bloc/account_bloc.dart'; +import '../state/app/modules/account/bloc/account_state.dart'; +import '../utils/haptics.dart'; +import 'app_progress_indicator.dart'; +import 'async_action_button.dart'; +import 'centered_leading.dart'; +import 'confirm_dialog.dart'; +import 'details_bottom_sheet.dart'; +import 'user_avatar.dart'; + +/// Lists the signed-in accounts: tap one to switch, add another, or sign out +/// of an inactive one. +Future showAccountSwitcherSheet(BuildContext context) { + final accountBloc = context.read(); + return showDetailsBottomSheet( + context, + header: Builder( + builder: (headerContext) => ListTile( + title: const Text('Konten'), + trailing: TextButton.icon( + icon: const Icon(Icons.person_add_alt_outlined, size: 18), + label: const Text('Hinzufügen'), + onPressed: () { + Navigator.pop(headerContext); + unawaited(startAddAccount(accountBloc)); + }, + ), + ), + ), + children: (sheetContext) => [ + ValueListenableBuilder( + valueListenable: SessionManager().accounts, + builder: (context, index, _) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final account in index.accounts) + _AccountTile( + account: account, + active: account.id == index.activeId, + accountBloc: accountBloc, + ), + ], + ), + ), + ], + ); +} + +/// Opens the login for another account; the active one is parked meanwhile. +Future startAddAccount(AccountBloc accountBloc) async { + await SessionLifecycle.beginAddAccount(); + accountBloc.setStatus(AccountStatus.addingAccount); +} + +/// Stacked next to the account name, which needs the width more. +/// Only the horizontal padding is trimmed; height stays at a full tap target. +final ButtonStyle compactAccountButtonStyle = TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8), +); + +/// Below the sign-out button: adds an account, or opens the switcher once +/// there is more than one. +class AccountSwitchButton extends StatelessWidget { + const AccountSwitchButton({super.key}); + + @override + Widget build(BuildContext context) => ValueListenableBuilder( + valueListenable: SessionManager().accounts, + builder: (context, index, _) => index.accounts.length > 1 + ? TextButton.icon( + style: compactAccountButtonStyle, + icon: const Icon(Icons.switch_account_outlined, size: 18), + label: const Text('Wechseln'), + onPressed: () => showAccountSwitcherSheet(context), + ) + : TextButton.icon( + style: compactAccountButtonStyle, + icon: const Icon(Icons.person_add_alt_outlined, size: 18), + label: const Text('Hinzufügen'), + onPressed: () => startAddAccount(context.read()), + ), + ); +} + +class _AccountTile extends StatefulWidget { + final AccountEntry account; + final bool active; + final AccountBloc accountBloc; + + const _AccountTile({ + required this.account, + required this.active, + required this.accountBloc, + }); + + @override + State<_AccountTile> createState() => _AccountTileState(); +} + +class _AccountTileState extends State<_AccountTile> { + bool _busy = false; + + Future _switch() async { + Haptics.selection(); + setState(() => _busy = true); + final ok = await runWithErrorDialog( + context, + () => SessionLifecycle.switchTo(widget.account.id), + ); + if (!mounted) return; + setState(() => _busy = false); + if (!ok) return; + Navigator.pop(context); + widget.accountBloc.activated(SessionManager().activeAccount?.id); + } + + void _confirmRemove() => ConfirmDialog( + title: 'Abmelden?', + content: + '${widget.account.displayName ?? widget.account.label} wird von diesem Gerät abgemeldet und seine ' + 'lokal gespeicherten Daten werden gelöscht.', + confirmButton: 'Abmelden', + onConfirmAsync: () => SessionLifecycle.removeInactive(widget.account.id), + ).asDialog(context); + + @override + Widget build(BuildContext context) { + final account = widget.account; + return ListTile( + leading: CenteredLeading( + _busy + ? const SizedBox.square( + dimension: 24, + child: AppProgressIndicator.small(), + ) + : _AccountAvatar(account: account), + ), + title: Text( + account.displayName ?? account.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + account.isDemo ? '${account.label} (Demo)' : account.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + // Sized like the IconButton so both line up. + trailing: widget.active + ? const SizedBox.square( + dimension: kMinInteractiveDimension, + child: Icon(Icons.check), + ) + : IconButton( + icon: const Icon(Icons.logout_outlined), + tooltip: 'Abmelden', + onPressed: _busy ? null : _confirmRemove, + ), + onTap: widget.active || _busy ? null : _switch, + ); + } +} + +class _AccountAvatar extends StatelessWidget { + final AccountEntry account; + + const _AccountAvatar({required this.account}); + + @override + Widget build(BuildContext context) { + // Demo accounts have no real Nextcloud user behind them. + if (!account.isGuardian && !account.isDemo) { + return UserAvatar(id: account.label, size: 16); + } + final colors = Theme.of(context).colorScheme; + return CircleAvatar( + radius: 16, + backgroundColor: colors.secondaryContainer, + foregroundColor: colors.onSecondaryContainer, + child: account.isGuardian + ? const Icon(Icons.family_restroom_outlined, size: 18) + : Text(account.label.characters.first.toUpperCase()), + ); + } +} diff --git a/lib/widget/child_switcher.dart b/lib/widget/child_switcher.dart index c2d6e54..a671032 100644 --- a/lib/widget/child_switcher.dart +++ b/lib/widget/child_switcher.dart @@ -10,6 +10,7 @@ import 'async_action_button.dart'; import 'centered_leading.dart'; import 'details_bottom_sheet.dart'; import 'placeholder_view.dart'; +import 'user_avatar.dart'; /// AppBar action that shows the selected child and lets a guardian switch to /// another one. Invisible unless there is a choice to make. @@ -63,7 +64,7 @@ class ChildTile extends StatelessWidget { @override Widget build(BuildContext context) => ListTile( - leading: const CenteredLeading(Icon(Icons.face_outlined)), + leading: CenteredLeading(ChildAvatar(child: child)), title: Text(child.displayName), subtitle: child.className.isEmpty ? null @@ -73,6 +74,38 @@ class ChildTile extends StatelessWidget { ); } +/// Profile picture of a linked child, or its initials while the server does +/// not name the child's account. +class ChildAvatar extends StatelessWidget { + final GuardianChild child; + final int size; + + const ChildAvatar({required this.child, this.size = 20, super.key}); + + @override + Widget build(BuildContext context) { + final username = child.username; + if (username != null && username.isNotEmpty) { + return UserAvatar( + id: username, + size: size, + semanticLabel: child.displayName, + ); + } + final colors = Theme.of(context).colorScheme; + final initials = [child.firstName, child.lastName] + .where((part) => part.isNotEmpty) + .map((part) => part.characters.first.toUpperCase()) + .join(); + return CircleAvatar( + radius: size.toDouble(), + backgroundColor: colors.secondaryContainer, + foregroundColor: colors.onSecondaryContainer, + child: Text(initials, style: TextStyle(fontSize: size * 0.7)), + ); + } +} + /// Shown by per-child modules while a guardian has no linked child. The /// children come from `me/capabilities`, so a failed or pending load must not /// be presented as "no child assigned" — that sends parents to the secretariat diff --git a/lib/widget/user_avatar.dart b/lib/widget/user_avatar.dart index 2c43366..a76d0d2 100644 --- a/lib/widget/user_avatar.dart +++ b/lib/widget/user_avatar.dart @@ -162,7 +162,9 @@ Future _fetchAvatarPayload(String url) async { () => http.get( Uri.parse(url), headers: { - ...SessionManager().requireNextcloud().authHeaders, + // User avatars are public in Nextcloud, so the account switcher can + // show them while a guardian (no Nextcloud identity) is active. + ...?SessionManager().current?.nextcloud?.authHeaders, 'Accept': 'image/png,image/jpeg,image/webp,image/svg+xml', }, ), diff --git a/test/session/account_codec_test.dart b/test/session/account_codec_test.dart new file mode 100644 index 0000000..f52f3d8 --- /dev/null +++ b/test/session/account_codec_test.dart @@ -0,0 +1,162 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/session/account_codec.dart'; +import 'package:marianum_mobile/session/session.dart'; +import 'package:marianum_mobile/session/session_codec.dart'; + +void main() { + final school = CredentialSession(username: 'max', password: 'pw'); + const guardian = GuardianSession(email: 'eltern@x.de'); + + group('AccountIndex.activate', () { + test('adds an unknown account with its own namespace', () { + final index = AccountIndex.empty.activate(school, newId: 'a', now: 5); + expect(index.activeId, 'a'); + final entry = index.active!; + expect(entry.kind, SessionKeys.kindCredential); + expect(entry.label, 'max'); + expect(entry.namespace, 'a'); + expect(entry.lastUsed, 5); + }); + + test('legacy account keeps the un-namespaced storage', () { + final index = AccountIndex.empty.activate( + school, + newId: 'a', + namespace: '', + ); + expect(index.active!.namespace, ''); + }); + + test('a known account is reused instead of duplicated', () { + final index = AccountIndex.empty + .activate(school, newId: 'a') + .activate(guardian, newId: 'b') + .activate( + CredentialSession(username: 'max', password: 'new'), + newId: 'c', + now: 9, + ); + expect(index.accounts.map((e) => e.id), ['a', 'b']); + expect(index.activeId, 'a'); + expect(index.active!.lastUsed, 9); + }); + + test('demo and real account with the same name stay apart', () { + final index = AccountIndex.empty + .activate(school, newId: 'a') + .activate( + CredentialSession(username: 'max', password: 'demo', isDemo: true), + newId: 'b', + ); + expect(index.accounts, hasLength(2)); + }); + }); + + group('AccountIndex removal', () { + final index = AccountIndex.empty + .activate(school, newId: 'a', now: 1) + .activate(guardian, newId: 'b', now: 3) + .select('a', now: 2); + + test('removing the active account leaves none active', () { + final removed = index.remove('a'); + expect(removed.activeId, isNull); + expect(removed.accounts.map((e) => e.id), ['b']); + }); + + test('removing an inactive account keeps the active one', () { + expect(index.remove('b').activeId, 'a'); + }); + + test('takeover picks the most recently used remaining account', () { + final third = index.activate( + const GuardianSession(email: 'z@x.de'), + newId: 'c', + now: 1, + ); + expect(third.remove('a').mostRecentExcept(null)?.id, 'b'); + expect(index.mostRecentExcept('b')?.id, 'a'); + expect(AccountIndex.empty.mostRecentExcept(null), isNull); + }); + }); + + group('AccountIndex codec', () { + test('round trip', () { + final index = AccountIndex.empty + .activate(school, newId: 'a', namespace: '', now: 1) + .activate(guardian, newId: 'b', now: 2); + final decoded = AccountIndex.decode(index.encode()); + expect(decoded.activeId, 'b'); + expect(decoded.accounts, hasLength(2)); + expect(decoded.byId('a')!.namespace, ''); + expect(decoded.byId('b')!.isGuardian, isTrue); + expect(decoded.byId('b')!.lastUsed, 2); + }); + + test('display name survives rename and round trip', () { + final index = AccountIndex.empty + .activate(school, newId: 'a') + .activate(guardian, newId: 'b') + .rename('b', 'Erika Muster'); + final decoded = AccountIndex.decode(index.encode()); + expect(decoded.byId('b')!.displayName, 'Erika Muster'); + expect(decoded.byId('a')!.displayName, isNull); + expect(decoded.activeId, 'b'); + }); + + test('garbage decodes to an empty index', () { + expect(AccountIndex.decode(null).accounts, isEmpty); + expect(AccountIndex.decode('{nope').accounts, isEmpty); + expect(AccountIndex.decode('[]').accounts, isEmpty); + }); + + test('malformed entries are skipped', () { + final decoded = AccountIndex.decode( + '{"activeId":"a","accounts":[{"id":"a"},' + '{"id":"b","kind":"guardian","label":"x"}]}', + ); + expect(decoded.accounts.map((e) => e.id), ['b']); + expect(decoded.active, isNull); + }); + }); + + group('vault', () { + test('credential session survives the vault with app passwords', () { + final session = CredentialSession( + username: 'max', + password: 'pw', + appPassword: 'app', + appPasswordTalk: 'talk', + usesLoginFlow: true, + ); + final fields = decodeVault( + encodeVault({...sessionVaultFields(session), 'mc_bearer_token': 't'}), + ); + final restored = decodeSession(fields)! as CredentialSession; + expect(restored.username, 'max'); + expect(restored.password, 'pw'); + expect(restored.nextcloud.appPassword, 'app'); + expect(restored.nextcloud.appPasswordTalk, 'talk'); + expect(restored.nextcloud.usesLoginFlow, isTrue); + expect(fields['mc_bearer_token'], 't'); + }); + + test('guardian demo session survives the vault', () { + final fields = decodeVault( + encodeVault( + sessionVaultFields( + const GuardianSession(email: 'e@x.de', isDemo: true), + ), + ), + ); + final restored = decodeSession(fields)! as GuardianSession; + expect(restored.email, 'e@x.de'); + expect(restored.isDemo, isTrue); + expect(fields.containsKey(SessionKeys.username), isFalse); + }); + + test('broken vault yields no session', () { + expect(decodeSession(decodeVault('garbage')), isNull); + }); + }); +} diff --git a/test/share_intent/app_link_filter_test.dart b/test/share_intent/app_link_filter_test.dart new file mode 100644 index 0000000..8b9d18e --- /dev/null +++ b/test/share_intent/app_link_filter_test.dart @@ -0,0 +1,25 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/share_intent/share_intent_listener.dart'; + +void main() { + test('guardian login links are not shares', () { + expect( + ShareIntentListener.isAppLink( + 'https://connect.example.de/app/guardian-login?r=1&t=2', + ), + isTrue, + ); + }); + + test('ordinary shared text and urls stay shares', () { + expect( + ShareIntentListener.isAppLink('https://example.de/artikel'), + isFalse, + ); + expect( + ShareIntentListener.isAppLink('Guck mal /app/guardian-login'), + isFalse, + ); + expect(ShareIntentListener.isAppLink('Hallo'), isFalse); + }); +}