added support for multiple accounts, guardian login bugfixes, ui changes
This commit is contained in:
@@ -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_<id>`, `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.
|
||||
|
||||
@@ -16,9 +16,14 @@ class DeleteAppPassword {
|
||||
Future<void> 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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,8 @@ abstract class WebdavApi<T> {
|
||||
/// changes (app password minted/renewed, account switch) so it never keeps
|
||||
/// authenticating with stale credentials.
|
||||
static Future<WebDavClient> 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();
|
||||
|
||||
@@ -20,6 +20,15 @@ class MarianumConnectAuthInterceptor extends Interceptor {
|
||||
// each spawning a fresh row in api_tokens.
|
||||
Future<bool>? _pendingReLogin;
|
||||
|
||||
static Future<bool>? _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<void> 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;
|
||||
}
|
||||
|
||||
@@ -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<void> probeStored({
|
||||
required Future<void> Function() onInvalidated,
|
||||
required Future<void> 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');
|
||||
}
|
||||
|
||||
@@ -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<Map<String, String>> readAll() async => {
|
||||
for (final key in fieldKeys) key: ?await _storage.read(key: key),
|
||||
};
|
||||
|
||||
/// Restores fields from [readAll]; missing ones are deleted.
|
||||
Future<void> writeAll(Map<String, String> 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<void> clear() async {
|
||||
await _storage.delete(key: _tokenKey);
|
||||
await _storage.delete(key: _tokenIdKey);
|
||||
|
||||
@@ -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<void> revoke(String token) async {
|
||||
try {
|
||||
await MarianumConnectApi.plainDio().post<void>(
|
||||
MarianumConnectEndpoint.resolve('auth/logout'),
|
||||
options: Options(headers: {'Authorization': 'Bearer $token'}),
|
||||
);
|
||||
} on DioException catch (_) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> run() async {
|
||||
await user();
|
||||
}
|
||||
|
||||
/// The signed-in user (names, type). Throws like [run].
|
||||
Future<AuthLoginUser> user() async {
|
||||
final options = await _tokenStorage.requireBearerOptions('AuthMe');
|
||||
return guard(() async {
|
||||
await dio.get<void>(endpoint('auth/me'), options: options);
|
||||
final response = await dio.get<Map<String, dynamic>>(
|
||||
endpoint('auth/me'),
|
||||
options: options,
|
||||
);
|
||||
return AuthLoginUser.fromJson(response.data!);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String, Object?> json) =>
|
||||
|
||||
@@ -16,7 +16,9 @@ T _$identity<T>(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<GuardianChild> 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 extends Object?>(TResult Function( String id, String firstName, String lastName, String className)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(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 extends Object?>(TResult Function( String id, String firstName, String lastName, String className) $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(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 extends Object?>(TResult? Function( String id, String firstName, String lastName, String className)? $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(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<String, dynamic> 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<String, dynamic> 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?,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ _GuardianChild _$GuardianChildFromJson(Map<String, dynamic> json) =>
|
||||
firstName: json['firstName'] as String,
|
||||
lastName: json['lastName'] as String,
|
||||
className: json['className'] as String? ?? '',
|
||||
username: json['username'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$GuardianChildToJson(_GuardianChild instance) =>
|
||||
@@ -20,4 +21,5 @@ Map<String, dynamic> _$GuardianChildToJson(_GuardianChild instance) =>
|
||||
'firstName': instance.firstName,
|
||||
'lastName': instance.lastName,
|
||||
'className': instance.className,
|
||||
'username': instance.username,
|
||||
};
|
||||
|
||||
+139
-141
@@ -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<void> 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<void> 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<void> 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<void> main() async {
|
||||
providers: [
|
||||
BlocProvider<SettingsCubit>.value(value: settingsCubit),
|
||||
BlocProvider<AccountBloc>(
|
||||
create: (_) => AccountBloc(initialStatus: _initialAccountStatus()),
|
||||
create: (_) => AccountBloc(
|
||||
initialStatus: _initialAccountStatus(initialAccount),
|
||||
accountId: initialAccount?.id,
|
||||
),
|
||||
BlocProvider<BreakerBloc>(create: (_) => BreakerBloc()),
|
||||
BlocProvider<CapabilitiesCubit>(create: (_) => CapabilitiesCubit()),
|
||||
BlocProvider<NextcloudCapabilitiesCubit>(
|
||||
create: (_) => NextcloudCapabilitiesCubit(),
|
||||
),
|
||||
BlocProvider<ChatListBloc>(create: (_) => ChatListBloc()),
|
||||
BlocProvider<ChatBloc>(
|
||||
create: (ctx) => ChatBloc(chatListBloc: ctx.read<ChatListBloc>()),
|
||||
),
|
||||
BlocProvider<ChildSelectionCubit>(create: (_) => ChildSelectionCubit()),
|
||||
BlocProvider<ParentLettersBloc>(create: (_) => ParentLettersBloc()),
|
||||
],
|
||||
child: const PrimaryTimetableScope(child: Main()),
|
||||
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<Main> {
|
||||
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>();
|
||||
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<SettingsCubit>();
|
||||
final capabilitiesCubit = context.read<CapabilitiesCubit>();
|
||||
/// 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<SettingsCubit>();
|
||||
final capabilitiesCubit = scopeContext.read<CapabilitiesCubit>();
|
||||
unawaited(
|
||||
capabilitiesCubit.load().then((_) {
|
||||
if (!mounted) return;
|
||||
@@ -333,7 +337,20 @@ class _MainState extends State<Main> {
|
||||
_promptGuardianNotifications();
|
||||
}),
|
||||
);
|
||||
unawaited(context.read<NextcloudCapabilitiesCubit>().load());
|
||||
unawaited(scopeContext.read<NextcloudCapabilitiesCubit>().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<Main> {
|
||||
@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<void> _onGuardianLink() async {
|
||||
if (GuardianLinkListener.pending.value == null || !mounted) return;
|
||||
final accountBloc = context.read<AccountBloc>();
|
||||
if (accountBloc.state.status != AccountStatus.loggedIn) return;
|
||||
await SessionLifecycle.beginAddAccount();
|
||||
accountBloc.setStatus(AccountStatus.addingAccount);
|
||||
}
|
||||
|
||||
void _onUnauthorized() {
|
||||
if (!mounted) return;
|
||||
final accountBloc = context.read<AccountBloc>();
|
||||
@@ -394,25 +422,52 @@ class _MainState extends State<Main> {
|
||||
}
|
||||
|
||||
/// 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<SettingsCubit, Settings>(
|
||||
builder: (context, settings) {
|
||||
final devToolsSettings = settings.devToolsSettings;
|
||||
// Mirror the notification toggle into group-scoped storage so the FCM
|
||||
// background isolate and the iOS NSE can suppress rendering when off.
|
||||
unawaited(
|
||||
@@ -420,10 +475,36 @@ class _MainState extends State<Main> {
|
||||
settings.notificationSettings.enabled,
|
||||
),
|
||||
);
|
||||
return BlocConsumer<AccountBloc, AccountState>(
|
||||
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<NavigatorState>();
|
||||
}
|
||||
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,
|
||||
checkerboardOffscreenLayers: devToolsSettings.checkerboardOffscreenLayers,
|
||||
checkerboardRasterCacheImages:
|
||||
devToolsSettings.checkerboardRasterCacheImages,
|
||||
debugShowCheckedModeBanner: false,
|
||||
@@ -461,73 +542,16 @@ class _MainState extends State<Main> {
|
||||
child: LoaderOverlay(
|
||||
child: Breaker(
|
||||
breaker: BreakerArea.global,
|
||||
child: BlocConsumer<AccountBloc, AccountState>(
|
||||
listenWhen: (previous, current) =>
|
||||
child: BlocBuilder<AccountBloc, AccountState>(
|
||||
buildWhen: (previous, current) =>
|
||||
previous.status != current.status,
|
||||
listener: (context, accountState) {
|
||||
if (accountState.status == AccountStatus.loggedIn) {
|
||||
_onSessionActive();
|
||||
_showPostLoginSplash = true;
|
||||
_appMounted = false;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) setState(() => _appMounted = true);
|
||||
});
|
||||
_prefetchBaseData(context);
|
||||
}
|
||||
if (accountState.status != AccountStatus.loggedOut) return;
|
||||
// A pending share would otherwise survive logout and be
|
||||
// re-applied after re-login with file paths the OS may
|
||||
// already have evicted from the cache.
|
||||
ShareIntentListener.instance.clear();
|
||||
// Routes pushed via AppRoutes (e.g. Settings) live on the
|
||||
// root navigator and survive the home swap below, so they
|
||||
// would still cover the Login screen after logout. Pop
|
||||
// them here so the user immediately sees Login.
|
||||
final navigator = Navigator.of(context);
|
||||
if (navigator.canPop()) {
|
||||
navigator.popUntil((route) => route.isFirst);
|
||||
}
|
||||
// Capture bloc references before the post-frame callback
|
||||
// — by the time it runs the dialog/Settings context is
|
||||
// gone but this listener context is still valid.
|
||||
final settingsCubit = context.read<SettingsCubit>();
|
||||
final breakerBloc = context.read<BreakerBloc>();
|
||||
final capabilitiesCubit = context.read<CapabilitiesCubit>();
|
||||
final childSelectionCubit = context
|
||||
.read<ChildSelectionCubit>();
|
||||
final chatListBloc = context.read<ChatListBloc>();
|
||||
final parentLettersBloc = context.read<ParentLettersBloc>();
|
||||
final chatBloc = context.read<ChatBloc>();
|
||||
final nextcloudCapabilitiesCubit = context
|
||||
.read<NextcloudCapabilitiesCubit>();
|
||||
// Defer the actual wipe until after this frame so the
|
||||
// App tree (TimetableBloc/ChatListBloc watchers etc.)
|
||||
// is already torn down. Resetting blocs while App is
|
||||
// still in front caused a black-frame race.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
unawaited(
|
||||
_wipeUserState(
|
||||
settingsCubit: settingsCubit,
|
||||
childSelectionCubit: childSelectionCubit,
|
||||
chatListBloc: chatListBloc,
|
||||
parentLettersBloc: parentLettersBloc,
|
||||
chatBloc: chatBloc,
|
||||
breakerBloc: breakerBloc,
|
||||
capabilitiesCubit: capabilitiesCubit,
|
||||
nextcloudCapabilitiesCubit:
|
||||
nextcloudCapabilitiesCubit,
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
builder: (context, accountState) {
|
||||
switch (accountState.status) {
|
||||
case AccountStatus.loggedIn:
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (_appMounted)
|
||||
const App(key: ValueKey('app-shell')),
|
||||
if (_appMounted) const App(key: ValueKey('app-shell')),
|
||||
if (_showPostLoginSplash)
|
||||
PostLoginSplash(
|
||||
key: const ValueKey('post-login-splash'),
|
||||
@@ -540,6 +564,8 @@ class _MainState extends State<Main> {
|
||||
);
|
||||
case AccountStatus.loggedOut:
|
||||
return const Login();
|
||||
case AccountStatus.addingAccount:
|
||||
return const Login(addingAccount: true);
|
||||
case AccountStatus.undefined:
|
||||
return const AccountLoadingScreen();
|
||||
}
|
||||
@@ -549,43 +575,15 @@ class _MainState extends State<Main> {
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _wipeUserState({
|
||||
required SettingsCubit settingsCubit,
|
||||
required ChildSelectionCubit childSelectionCubit,
|
||||
required ChatListBloc chatListBloc,
|
||||
required ParentLettersBloc parentLettersBloc,
|
||||
required ChatBloc chatBloc,
|
||||
required BreakerBloc breakerBloc,
|
||||
required CapabilitiesCubit capabilitiesCubit,
|
||||
required NextcloudCapabilitiesCubit nextcloudCapabilitiesCubit,
|
||||
}) async {
|
||||
/// Wipes what outlives accounts once the last one signed out. Account data
|
||||
/// itself is removed by [SessionLifecycle.signOut].
|
||||
Future<void> _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<void> _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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -433,6 +433,14 @@ class PushRegistration {
|
||||
/// first, then the proxy) with the new token.
|
||||
Future<void> 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<void> 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
|
||||
|
||||
@@ -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<NavigatorState> rootNavigatorKey =
|
||||
/// Replaced per account (see `Main`), so a switch starts on a fresh
|
||||
/// navigator.
|
||||
static GlobalKey<NavigatorState> rootNavigatorKey =
|
||||
GlobalKey<NavigatorState>();
|
||||
|
||||
/// A context that is a descendant of the root navigator (its overlay), safe to
|
||||
|
||||
@@ -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<String, dynamic> 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<AccountEntry> 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<String, String> 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<String, String> fields) => jsonEncode(fields);
|
||||
|
||||
Map<String, String> 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 {};
|
||||
}
|
||||
}
|
||||
@@ -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<String?> 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<void> 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<String?> 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<void> 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<void> 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<void> 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<String> 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<void> 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<void> 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<void> _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<void> _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<void> _clearPushStores() async {
|
||||
try {
|
||||
await NidStore().clear();
|
||||
await ChatThreadStore().clearAll();
|
||||
} on Object catch (e) {
|
||||
log('Push store cleanup failed: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AccountIndex> 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<void> 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<void> _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<void> signOut() async {
|
||||
/// Wipes the active slots and forgets the active account. Other accounts
|
||||
/// stay in their vaults; see [activate].
|
||||
Future<AccountEntry?> 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<void> 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<void> 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<void> 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<String, String>)> 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<void> forget(String id) async {
|
||||
await _secureStorage.delete(key: _vaultKey(id));
|
||||
await _saveIndex(accounts.value.remove(id));
|
||||
}
|
||||
|
||||
Future<void> _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<void> 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<void> _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<void> _migrateFromLegacyStorage() async {
|
||||
|
||||
@@ -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<SharedMediaFile> items) {
|
||||
if (items.isEmpty) return null;
|
||||
final files = <String>[];
|
||||
@@ -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;
|
||||
|
||||
@@ -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<BreakerBloc>(create: (_) => BreakerBloc()),
|
||||
BlocProvider<CapabilitiesCubit>(create: (_) => CapabilitiesCubit()),
|
||||
BlocProvider<NextcloudCapabilitiesCubit>(
|
||||
create: (_) => NextcloudCapabilitiesCubit(),
|
||||
),
|
||||
BlocProvider<ChatListBloc>(create: (_) => ChatListBloc()),
|
||||
BlocProvider<ChatBloc>(
|
||||
create: (ctx) => ChatBloc(chatListBloc: ctx.read<ChatListBloc>()),
|
||||
),
|
||||
BlocProvider<ChildSelectionCubit>(create: (_) => ChildSelectionCubit()),
|
||||
BlocProvider<ParentLettersBloc>(create: (_) => ParentLettersBloc()),
|
||||
],
|
||||
child: 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<AccountBloc>().state.status != AccountStatus.loggedIn) {
|
||||
return;
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) widget.onActive(context);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => widget.child;
|
||||
}
|
||||
@@ -4,12 +4,38 @@ import 'account_event.dart';
|
||||
import 'account_state.dart';
|
||||
|
||||
class AccountBloc extends Bloc<AccountEvent, AccountState> {
|
||||
AccountBloc({AccountStatus initialStatus = AccountStatus.undefined})
|
||||
: super(AccountState(status: initialStatus)) {
|
||||
AccountBloc({
|
||||
AccountStatus initialStatus = AccountStatus.undefined,
|
||||
String? accountId,
|
||||
}) : super(AccountState(status: initialStatus, accountId: accountId)) {
|
||||
on<AccountStatusChanged>(
|
||||
(event, emit) => emit(state.copyWith(status: event.status)),
|
||||
(event, emit) => emit(
|
||||
AccountState(
|
||||
status: event.status,
|
||||
accountId: event.status == AccountStatus.loggedOut
|
||||
? null
|
||||
: state.accountId,
|
||||
),
|
||||
),
|
||||
);
|
||||
on<AccountActivated>(
|
||||
(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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ class SettingsCubit extends HydratedCubit<Settings> {
|
||||
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) {
|
||||
|
||||
@@ -64,7 +64,10 @@ class _PrimaryTimetableScopeState extends State<PrimaryTimetableScope> {
|
||||
Widget build(BuildContext context) => MultiBlocListener(
|
||||
listeners: [
|
||||
BlocListener<AccountBloc, AccountState>(
|
||||
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<CapabilitiesCubit, CapabilitiesState>(
|
||||
@@ -73,9 +76,6 @@ class _PrimaryTimetableScopeState extends State<PrimaryTimetableScope> {
|
||||
),
|
||||
BlocListener<ChildSelectionCubit, String?>(listener: (_, _) => _sync()),
|
||||
],
|
||||
child: BlocProvider<TimetableBloc>.value(
|
||||
value: _bloc,
|
||||
child: widget.child,
|
||||
),
|
||||
child: BlocProvider<TimetableBloc>.value(value: _bloc, child: widget.child),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<String, Storage> _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<void> 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<void> 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<void> 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<Storage> _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;
|
||||
}
|
||||
}
|
||||
+67
-15
@@ -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<Login> createState() => _LoginState();
|
||||
@@ -85,7 +89,7 @@ class _LoginState extends State<Login> with SingleTickerProviderStateMixin {
|
||||
return;
|
||||
}
|
||||
final signedIn = await _guardianController.submitLink(link);
|
||||
if (signedIn && mounted) _onLoginSuccess();
|
||||
if (signedIn && mounted) await _onLoginSuccess();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -104,39 +108,79 @@ class _LoginState extends State<Login> with SingleTickerProviderStateMixin {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onLoginSuccess() {
|
||||
Future<void> _onLoginSuccess() async {
|
||||
Haptics.heavyAccent();
|
||||
final accountBloc = context.read<AccountBloc>();
|
||||
// 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<TickerCanceled>((_, _) {});
|
||||
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<AccountBloc>().setStatus(AccountStatus.loggedIn);
|
||||
});
|
||||
accountBloc.activated(accountId, freshLogin: true);
|
||||
}
|
||||
|
||||
Future<void> _cancelAddAccount() async {
|
||||
final accountBloc = context.read<AccountBloc>();
|
||||
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(
|
||||
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,
|
||||
),
|
||||
),
|
||||
title: const Text('Konto hinzufügen'),
|
||||
)
|
||||
: null,
|
||||
body: FadeTransition(
|
||||
opacity: _fade,
|
||||
child: SafeArea(
|
||||
@@ -157,7 +201,7 @@ class _LoginState extends State<Login> with SingleTickerProviderStateMixin {
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
const LoginHeader(),
|
||||
LoginHeader(addingAccount: widget.addingAccount),
|
||||
const SizedBox(height: 28),
|
||||
_buildCard(),
|
||||
if (_audience != null)
|
||||
@@ -188,9 +232,14 @@ class _LoginState extends State<Login> with SingleTickerProviderStateMixin {
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
),
|
||||
const Column(
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [_EndpointLink(), LoginFooter()],
|
||||
children: [
|
||||
// The new account must live on the server the app
|
||||
// already talks to.
|
||||
if (!widget.addingAccount) const _EndpointLink(),
|
||||
const LoginFooter(),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -200,7 +249,10 @@ class _LoginState extends State<Login> with SingleTickerProviderStateMixin {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
bool get _busy => _controller.loading || _guardianController.loading;
|
||||
}
|
||||
|
||||
/// Subtle text link above the footer that surfaces the currently selected
|
||||
|
||||
@@ -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<void> _discardPreviousAccount() async {
|
||||
await const MarianumConnectTokenStorage().clear();
|
||||
await WidgetSync.clear();
|
||||
|
||||
@@ -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<GuardianLoginCard> {
|
||||
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<GuardianLoginCard> {
|
||||
const SizedBox(height: 20),
|
||||
LoginSubmitButton(
|
||||
key: const Key('guardian-verify-button'),
|
||||
label: 'Anmelden',
|
||||
label: widget.addingAccount ? 'Hinzufügen' : 'Anmelden',
|
||||
loading: _controller.loading,
|
||||
onPressed: _submitCode,
|
||||
),
|
||||
|
||||
@@ -9,24 +9,35 @@ enum LoginAudience { school, guardian }
|
||||
class LoginAudienceCard extends StatelessWidget {
|
||||
final ValueChanged<LoginAudience> 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),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<LoginCard> {
|
||||
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<LoginCard> {
|
||||
const SizedBox(height: 20),
|
||||
LoginSubmitButton(
|
||||
key: const Key('login-submit-button'),
|
||||
label: 'Anmelden',
|
||||
label: widget.addingAccount ? 'Hinzufügen' : 'Anmelden',
|
||||
loading: loading,
|
||||
onPressed: _submit,
|
||||
),
|
||||
|
||||
@@ -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,18 +45,43 @@ class _GuardianAccount extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final children = context.watch<CapabilitiesCubit>().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<AccountIndex>(
|
||||
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<void> _loadDisplayName() async {
|
||||
Future<void> _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<void> _confirmLogout(BuildContext context) async {
|
||||
final accountBloc = context.read<AccountBloc>();
|
||||
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<bool>(
|
||||
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<AccountBloc>().setStatus(AccountStatus.loggedOut);
|
||||
if (confirmed != true) return;
|
||||
accountBloc.activated(nextAccountId);
|
||||
}
|
||||
|
||||
Future<void> _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 {
|
||||
|
||||
@@ -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<void> showAccountSwitcherSheet(BuildContext context) {
|
||||
final accountBloc = context.read<AccountBloc>();
|
||||
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<AccountIndex>(
|
||||
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<void> 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<AccountIndex>(
|
||||
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<AccountBloc>()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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<void> _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()),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -162,7 +162,9 @@ Future<AvatarPayload?> _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',
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user