diff --git a/lib/api/marianumconnect/auth/session_validator.dart b/lib/api/marianumconnect/auth/session_validator.dart index 7a66392..bfef92d 100644 --- a/lib/api/marianumconnect/auth/session_validator.dart +++ b/lib/api/marianumconnect/auth/session_validator.dart @@ -29,7 +29,15 @@ class SessionValidator { } on AuthException catch (e) { if (e.statusCode != 401) return; log('MC: stored session rejected — forcing re-login'); - await SessionLifecycle.signOut(); + await SessionLifecycle.signOut( + notice: switch (session) { + CredentialSession() => + 'Deine Zugangsdaten wurden vom Server abgelehnt. Vermutlich ' + 'wurde dein Passwort geändert. Bitte melde dich erneut an.', + GuardianSession() => + 'Deine Anmeldung ist abgelaufen. Bitte melde dich erneut an.', + }, + ); await onInvalidated(); } catch (e) { log('MC: background session check failed (transient): $e'); diff --git a/lib/api/marianumconnect/queries/absence/absence_prefill.dart b/lib/api/marianumconnect/queries/absence/absence_prefill.dart index 0f4e901..dda3362 100644 --- a/lib/api/marianumconnect/queries/absence/absence_prefill.dart +++ b/lib/api/marianumconnect/queries/absence/absence_prefill.dart @@ -1,3 +1,4 @@ +import '../../../errors/app_exception.dart'; import '../../marianumconnect_query.dart'; import 'absence_prefill_response.dart'; @@ -13,3 +14,17 @@ class AbsencePrefill extends MarianumConnectQuery { queryParameters: {'childId': ?childId}, ); } + +/// The prefill of a guardian's report is missing a field the form cannot ask +/// for: name and class come from the child and are not editable there, so an +/// incomplete prefill would leave a locked, unsubmittable form behind. +class AbsencePrefillIncompleteException extends AppException { + const AbsencePrefillIncompleteException({super.technicalDetails}) + : super( + userMessage: + 'Für dieses Kind sind in der Schulverwaltung noch nicht alle ' + 'Angaben hinterlegt, die für eine Abwesenheitsmeldung nötig sind. ' + 'Bitte wende dich an das Sekretariat.', + allowRetry: false, + ); +} diff --git a/lib/api/marianumconnect/queries/auth_guardian/auth_guardian_request.dart b/lib/api/marianumconnect/queries/auth_guardian/auth_guardian_request.dart index 57adf85..431e143 100644 --- a/lib/api/marianumconnect/queries/auth_guardian/auth_guardian_request.dart +++ b/lib/api/marianumconnect/queries/auth_guardian/auth_guardian_request.dart @@ -30,8 +30,10 @@ class AuthGuardianRequestResponse { } /// Starts a passwordless guardian login: the server mails a code and a link. -/// The answer is identical for unknown addresses, so it reveals nothing about -/// which e-mails are registered. +/// An unknown address is reported as such (`email_not_registered`) instead of +/// being answered like a known one — a deliberate trade: without it guardians +/// wait for a mail that never arrives. Registered addresses are therefore +/// enumerable; keep that in mind when changing the server contract. class AuthGuardianRequest extends MarianumConnectQuery { AuthGuardianRequest({Dio? dio}) : super(dio: dio ?? MarianumConnectApi.plainDio()); @@ -52,7 +54,7 @@ class AuthGuardianRequest extends MarianumConnectQuery { ); return AuthGuardianRequestResponse.fromJson(response.data!); } on DioException catch (e) { - throw GuardianLoginException.fromDio(e); + throw GuardianLoginException.fromDio(e, verifying: false); } } } diff --git a/lib/api/marianumconnect/queries/auth_guardian/guardian_login_exception.dart b/lib/api/marianumconnect/queries/auth_guardian/guardian_login_exception.dart index bd8c3b5..c9ebbd5 100644 --- a/lib/api/marianumconnect/queries/auth_guardian/guardian_login_exception.dart +++ b/lib/api/marianumconnect/queries/auth_guardian/guardian_login_exception.dart @@ -31,14 +31,17 @@ class GuardianLoginException extends AppException { /// Maps a failed guardian auth call. Only 4xx answers that carry a guardian /// login reason become [GuardianLoginException]; everything else keeps the /// generic MarianumConnect mapping (network, 5xx, …). - static AppException fromDio(DioException e) { + /// + /// [verifying] false marks the e-mail step, where no code exists yet: a bare + /// 401 (reverse proxy, gateway) must not be reported as a wrong code there. + static AppException fromDio(DioException e, {bool verifying = true}) { final response = e.response; final status = response?.statusCode; if (status == null || status < 400 || status >= 500) { return mapMarianumConnectError(e); } final (code, attemptsLeft) = _parseBody(response!.data); - final error = _errorFor(code, status); + final error = _errorFor(code, status, verifying: verifying); if (error == null) return mapMarianumConnectError(e); return GuardianLoginException( error, @@ -89,8 +92,11 @@ class GuardianLoginException extends AppException { return (marianumConnectErrorCode(data), attempts is int ? attempts : null); } - static GuardianLoginError? _errorFor(String? code, int status) => - switch (code) { + static GuardianLoginError? _errorFor( + String? code, + int status, { + required bool verifying, + }) => switch (code) { 'invalid_request' => GuardianLoginError.invalidRequest, 'email_not_registered' => GuardianLoginError.emailNotRegistered, 'account_disabled' => GuardianLoginError.accountDisabled, @@ -100,7 +106,7 @@ class GuardianLoginException extends AppException { 'request_expired' => GuardianLoginError.requestExpired, 'too_many_attempts' => GuardianLoginError.tooManyAttempts, _ => switch (status) { - 401 => GuardianLoginError.invalidCode, + 401 when verifying => GuardianLoginError.invalidCode, // The server names an unknown address explicitly // (`email_not_registered`); a bare 404/405 means the endpoint itself // is missing, i.e. a server version without guardian login. diff --git a/lib/main.dart b/lib/main.dart index 571aa9d..29d9be6 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -573,14 +573,14 @@ Future _wipeUserState({ // 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([ - childSelectionCubit.reset(), chatListBloc.reset(), parentLettersBloc.reset(), chatBloc.reset(), breakerBloc.reset(), - capabilitiesCubit.reset(), - nextcloudCapabilitiesCubit.reset(), ]); final prefs = await SharedPreferences.getInstance(); await prefs.clear(); diff --git a/lib/push/notification_permission_prompt.dart b/lib/push/notification_permission_prompt.dart index ec36275..8b125f2 100644 --- a/lib/push/notification_permission_prompt.dart +++ b/lib/push/notification_permission_prompt.dart @@ -141,6 +141,10 @@ Future _maybePrompt( if (prompt.spentOnDisplay) { prompt.markShown(settings.val(write: true).notificationSettings); } + // The OS prompt (and its "declined" follow-up) outlive this dialog, so + // hold on to them: releasing the guard at the dialog's close would let a + // second occasion stack another dialog over the pending OS prompt. + Future? permissionRequest; await showDialog( context: context, builder: ConfirmDialog( @@ -150,9 +154,10 @@ Future _maybePrompt( confirmButton: 'Weiter', cancelButton: null, onConfirm: () => - unawaited(_requestPermission(context, settings, prompt)), + permissionRequest = _requestPermission(context, settings, prompt), ).build, ); + await permissionRequest; } finally { _promptInFlight = false; } diff --git a/lib/session/session_lifecycle.dart b/lib/session/session_lifecycle.dart index c591647..33afa98 100644 --- a/lib/session/session_lifecycle.dart +++ b/lib/session/session_lifecycle.dart @@ -1,16 +1,24 @@ import 'dart:developer'; +import 'package:flutter/foundation.dart'; + import '../api/marianumconnect/queries/auth_logout/auth_logout.dart'; import '../auth_link/guardian_link_listener.dart'; import '../push/push_registration.dart'; import 'session_manager.dart'; abstract final class SessionLifecycle { + /// Why the last sign-out happened, when the user did not ask for it. The + /// login screen shows it once and clears it — without it an expired session + /// just drops the user on the login screen with no explanation. + static final ValueNotifier signOutNotice = ValueNotifier(null); + /// Ordered teardown: unregister push and revoke the Nextcloud app passwords /// (while those credentials still exist), then revoke the MC bearer token, /// finally wipe the local session. Each step is best-effort so an offline /// sign-out still reaches a clean local state. - static Future signOut() async { + static Future signOut({String? notice}) async { + signOutNotice.value = notice; try { await PushRegistration().logoutCleanup(); } on Object catch (e) { diff --git a/lib/state/app/modules/capabilities/bloc/capabilities_cubit.dart b/lib/state/app/modules/capabilities/bloc/capabilities_cubit.dart index d5aef75..9240d2f 100644 --- a/lib/state/app/modules/capabilities/bloc/capabilities_cubit.dart +++ b/lib/state/app/modules/capabilities/bloc/capabilities_cubit.dart @@ -25,8 +25,9 @@ class CapabilitiesCubit extends HydratedCubit { /// Refreshes capabilities from the server. On any failure (endpoint not yet /// live, network error, 4xx) the previously hydrated flags are kept but the - /// state is marked `loaded` — a failed fetch never silently grants a - /// capability, and an offline launch keeps whatever was cached. + /// state is marked `loaded` and `loadFailed` — a failed fetch never silently + /// grants a capability, an offline launch keeps whatever was cached, and the + /// UI can offer a retry instead of claiming a guardian has no children. Future load() async { if (DemoMode.active) { emit( @@ -51,11 +52,11 @@ class CapabilitiesCubit extends HydratedCubit { ); } catch (e) { log('Failed to load capabilities: $e'); - emit(state.copyWith(loaded: true)); + emit(state.copyWith(loaded: true, loadFailed: true)); } } - Future reset() async => emit(const CapabilitiesState()); + void reset() => emit(const CapabilitiesState()); @override CapabilitiesState fromJson(Map json) { diff --git a/lib/state/app/modules/capabilities/bloc/capabilities_state.dart b/lib/state/app/modules/capabilities/bloc/capabilities_state.dart index c0c8368..ced9de9 100644 --- a/lib/state/app/modules/capabilities/bloc/capabilities_state.dart +++ b/lib/state/app/modules/capabilities/bloc/capabilities_state.dart @@ -25,6 +25,12 @@ abstract class CapabilitiesState with _$CapabilitiesState { // observed at least once this session. Lets the UI distinguish "still // unknown" from "confirmed not allowed". @Default(false) bool loaded, + // True while the last attempt failed. Not persisted: a stale failure from + // the previous run must not colour a fresh start. Together with [loaded] + // it separates "confirmed no children" from "could not ask". + @JsonKey(includeToJson: false, includeFromJson: false) + @Default(false) + bool loadFailed, }) = _CapabilitiesState; factory CapabilitiesState.fromJson(Map json) => diff --git a/lib/state/app/modules/capabilities/bloc/capabilities_state.freezed.dart b/lib/state/app/modules/capabilities/bloc/capabilities_state.freezed.dart index 23fd9f0..2038316 100644 --- a/lib/state/app/modules/capabilities/bloc/capabilities_state.freezed.dart +++ b/lib/state/app/modules/capabilities/bloc/capabilities_state.freezed.dart @@ -16,7 +16,7 @@ T _$identity(T value) => value; /// @nodoc mixin _$CapabilitiesState { - bool get viewForeignTimetables; bool get pushNotifications; int? get timetablePastDays; int? get timetableFutureDays; String? get userType; List get children; bool get loaded; + bool get viewForeignTimetables; bool get pushNotifications; int? get timetablePastDays; int? get timetableFutureDays; String? get userType; List get children; bool get loaded;@JsonKey(includeToJson: false, includeFromJson: false) bool get loadFailed; /// Create a copy of CapabilitiesState /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -30,20 +30,20 @@ $CapabilitiesStateCopyWith get copyWith => _$CapabilitiesStat @override bool operator ==(Object other) { final _this = this as CapabilitiesState; - return identical(this, other) || (other.runtimeType == runtimeType&&other is CapabilitiesState&&(identical(other.viewForeignTimetables, _this.viewForeignTimetables) || other.viewForeignTimetables == _this.viewForeignTimetables)&&(identical(other.pushNotifications, _this.pushNotifications) || other.pushNotifications == _this.pushNotifications)&&(identical(other.timetablePastDays, _this.timetablePastDays) || other.timetablePastDays == _this.timetablePastDays)&&(identical(other.timetableFutureDays, _this.timetableFutureDays) || other.timetableFutureDays == _this.timetableFutureDays)&&(identical(other.userType, _this.userType) || other.userType == _this.userType)&&const DeepCollectionEquality().equals(other.children, _this.children)&&(identical(other.loaded, _this.loaded) || other.loaded == _this.loaded)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is CapabilitiesState&&(identical(other.viewForeignTimetables, _this.viewForeignTimetables) || other.viewForeignTimetables == _this.viewForeignTimetables)&&(identical(other.pushNotifications, _this.pushNotifications) || other.pushNotifications == _this.pushNotifications)&&(identical(other.timetablePastDays, _this.timetablePastDays) || other.timetablePastDays == _this.timetablePastDays)&&(identical(other.timetableFutureDays, _this.timetableFutureDays) || other.timetableFutureDays == _this.timetableFutureDays)&&(identical(other.userType, _this.userType) || other.userType == _this.userType)&&const DeepCollectionEquality().equals(other.children, _this.children)&&(identical(other.loaded, _this.loaded) || other.loaded == _this.loaded)&&(identical(other.loadFailed, _this.loadFailed) || other.loadFailed == _this.loadFailed)); } @JsonKey(includeFromJson: false, includeToJson: false) @override int get hashCode { final _this = this as CapabilitiesState; - return Object.hash(runtimeType,_this.viewForeignTimetables,_this.pushNotifications,_this.timetablePastDays,_this.timetableFutureDays,_this.userType,const DeepCollectionEquality().hash(_this.children),_this.loaded); + return Object.hash(runtimeType,_this.viewForeignTimetables,_this.pushNotifications,_this.timetablePastDays,_this.timetableFutureDays,_this.userType,const DeepCollectionEquality().hash(_this.children),_this.loaded,_this.loadFailed); } @override String toString() { final _this = this as CapabilitiesState; - return 'CapabilitiesState(viewForeignTimetables: ${_this.viewForeignTimetables}, pushNotifications: ${_this.pushNotifications}, timetablePastDays: ${_this.timetablePastDays}, timetableFutureDays: ${_this.timetableFutureDays}, userType: ${_this.userType}, children: ${_this.children}, loaded: ${_this.loaded})'; + return 'CapabilitiesState(viewForeignTimetables: ${_this.viewForeignTimetables}, pushNotifications: ${_this.pushNotifications}, timetablePastDays: ${_this.timetablePastDays}, timetableFutureDays: ${_this.timetableFutureDays}, userType: ${_this.userType}, children: ${_this.children}, loaded: ${_this.loaded}, loadFailed: ${_this.loadFailed})'; } @@ -54,7 +54,7 @@ abstract mixin class $CapabilitiesStateCopyWith<$Res> { factory $CapabilitiesStateCopyWith(CapabilitiesState value, $Res Function(CapabilitiesState) _then) = _$CapabilitiesStateCopyWithImpl; @useResult $Res call({ - bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, List children, bool loaded + bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, List children, bool loaded,@JsonKey(includeToJson: false, includeFromJson: false) bool loadFailed }); @@ -71,7 +71,7 @@ class _$CapabilitiesStateCopyWithImpl<$Res> /// Create a copy of CapabilitiesState /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? viewForeignTimetables = null,Object? pushNotifications = null,Object? timetablePastDays = freezed,Object? timetableFutureDays = freezed,Object? userType = freezed,Object? children = null,Object? loaded = null,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? viewForeignTimetables = null,Object? pushNotifications = null,Object? timetablePastDays = freezed,Object? timetableFutureDays = freezed,Object? userType = freezed,Object? children = null,Object? loaded = null,Object? loadFailed = null,}) { return _then(CapabilitiesState( viewForeignTimetables: null == viewForeignTimetables ? _self.viewForeignTimetables : viewForeignTimetables // ignore: cast_nullable_to_non_nullable as bool,pushNotifications: null == pushNotifications ? _self.pushNotifications : pushNotifications // ignore: cast_nullable_to_non_nullable @@ -80,6 +80,7 @@ as int?,timetableFutureDays: freezed == timetableFutureDays ? _self.timetableFut as int?,userType: freezed == userType ? _self.userType : userType // ignore: cast_nullable_to_non_nullable as String?,children: null == children ? _self.children : children // ignore: cast_nullable_to_non_nullable as List,loaded: null == loaded ? _self.loaded : loaded // ignore: cast_nullable_to_non_nullable +as bool,loadFailed: null == loadFailed ? _self.loadFailed : loadFailed // ignore: cast_nullable_to_non_nullable as bool, )); } @@ -165,10 +166,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, List children, bool loaded)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, List children, bool loaded, @JsonKey(includeToJson: false, includeFromJson: false) bool loadFailed)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _CapabilitiesState() when $default != null: -return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timetablePastDays,_that.timetableFutureDays,_that.userType,_that.children,_that.loaded);case _: +return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timetablePastDays,_that.timetableFutureDays,_that.userType,_that.children,_that.loaded,_that.loadFailed);case _: return orElse(); } @@ -186,10 +187,10 @@ return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timeta /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, List children, bool loaded) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, List children, bool loaded, @JsonKey(includeToJson: false, includeFromJson: false) bool loadFailed) $default,) {final _that = this; switch (_that) { case _CapabilitiesState(): -return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timetablePastDays,_that.timetableFutureDays,_that.userType,_that.children,_that.loaded);case _: +return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timetablePastDays,_that.timetableFutureDays,_that.userType,_that.children,_that.loaded,_that.loadFailed);case _: throw StateError('Unexpected subclass'); } @@ -206,10 +207,10 @@ return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timeta /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, List children, bool loaded)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, List children, bool loaded, @JsonKey(includeToJson: false, includeFromJson: false) bool loadFailed)? $default,) {final _that = this; switch (_that) { case _CapabilitiesState() when $default != null: -return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timetablePastDays,_that.timetableFutureDays,_that.userType,_that.children,_that.loaded);case _: +return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timetablePastDays,_that.timetableFutureDays,_that.userType,_that.children,_that.loaded,_that.loadFailed);case _: return null; } @@ -221,7 +222,7 @@ return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timeta @JsonSerializable() class _CapabilitiesState extends CapabilitiesState { - const _CapabilitiesState({this.viewForeignTimetables = false, this.pushNotifications = false, this.timetablePastDays, this.timetableFutureDays, this.userType, List children = const [], this.loaded = false}): _children = children,super._(); + const _CapabilitiesState({this.viewForeignTimetables = false, this.pushNotifications = false, this.timetablePastDays, this.timetableFutureDays, this.userType, List children = const [], this.loaded = false, @JsonKey(includeToJson: false, includeFromJson: false) this.loadFailed = false}): _children = children,super._(); factory _CapabilitiesState.fromJson(Map json) => _$CapabilitiesStateFromJson(json); @override@JsonKey() final bool viewForeignTimetables; @@ -237,6 +238,7 @@ class _CapabilitiesState extends CapabilitiesState { } @override@JsonKey() final bool loaded; +@override@JsonKey(includeToJson: false, includeFromJson: false) final bool loadFailed; /// Create a copy of CapabilitiesState /// with the given fields replaced by the non-null parameter values. @@ -251,18 +253,18 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _CapabilitiesState&&(identical(other.viewForeignTimetables, viewForeignTimetables) || other.viewForeignTimetables == viewForeignTimetables)&&(identical(other.pushNotifications, pushNotifications) || other.pushNotifications == pushNotifications)&&(identical(other.timetablePastDays, timetablePastDays) || other.timetablePastDays == timetablePastDays)&&(identical(other.timetableFutureDays, timetableFutureDays) || other.timetableFutureDays == timetableFutureDays)&&(identical(other.userType, userType) || other.userType == userType)&&const DeepCollectionEquality().equals(other.children, _children)&&(identical(other.loaded, loaded) || other.loaded == loaded)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _CapabilitiesState&&(identical(other.viewForeignTimetables, viewForeignTimetables) || other.viewForeignTimetables == viewForeignTimetables)&&(identical(other.pushNotifications, pushNotifications) || other.pushNotifications == pushNotifications)&&(identical(other.timetablePastDays, timetablePastDays) || other.timetablePastDays == timetablePastDays)&&(identical(other.timetableFutureDays, timetableFutureDays) || other.timetableFutureDays == timetableFutureDays)&&(identical(other.userType, userType) || other.userType == userType)&&const DeepCollectionEquality().equals(other.children, _children)&&(identical(other.loaded, loaded) || other.loaded == loaded)&&(identical(other.loadFailed, loadFailed) || other.loadFailed == loadFailed)); } @JsonKey(includeFromJson: false, includeToJson: false) @override int get hashCode { - return Object.hash(runtimeType,viewForeignTimetables,pushNotifications,timetablePastDays,timetableFutureDays,userType,const DeepCollectionEquality().hash(_children),loaded); + return Object.hash(runtimeType,viewForeignTimetables,pushNotifications,timetablePastDays,timetableFutureDays,userType,const DeepCollectionEquality().hash(_children),loaded,loadFailed); } @override String toString() { - return 'CapabilitiesState(viewForeignTimetables: $viewForeignTimetables, pushNotifications: $pushNotifications, timetablePastDays: $timetablePastDays, timetableFutureDays: $timetableFutureDays, userType: $userType, children: $children, loaded: $loaded)'; + return 'CapabilitiesState(viewForeignTimetables: $viewForeignTimetables, pushNotifications: $pushNotifications, timetablePastDays: $timetablePastDays, timetableFutureDays: $timetableFutureDays, userType: $userType, children: $children, loaded: $loaded, loadFailed: $loadFailed)'; } @@ -273,7 +275,7 @@ abstract mixin class _$CapabilitiesStateCopyWith<$Res> implements $CapabilitiesS factory _$CapabilitiesStateCopyWith(_CapabilitiesState value, $Res Function(_CapabilitiesState) _then) = __$CapabilitiesStateCopyWithImpl; @override @useResult $Res call({ - bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, List children, bool loaded + bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, List children, bool loaded,@JsonKey(includeToJson: false, includeFromJson: false) bool loadFailed }); @@ -290,7 +292,7 @@ class __$CapabilitiesStateCopyWithImpl<$Res> /// Create a copy of CapabilitiesState /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? viewForeignTimetables = null,Object? pushNotifications = null,Object? timetablePastDays = freezed,Object? timetableFutureDays = freezed,Object? userType = freezed,Object? children = null,Object? loaded = null,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? viewForeignTimetables = null,Object? pushNotifications = null,Object? timetablePastDays = freezed,Object? timetableFutureDays = freezed,Object? userType = freezed,Object? children = null,Object? loaded = null,Object? loadFailed = null,}) { return _then(_CapabilitiesState( viewForeignTimetables: null == viewForeignTimetables ? _self.viewForeignTimetables : viewForeignTimetables // ignore: cast_nullable_to_non_nullable as bool,pushNotifications: null == pushNotifications ? _self.pushNotifications : pushNotifications // ignore: cast_nullable_to_non_nullable @@ -299,6 +301,7 @@ as int?,timetableFutureDays: freezed == timetableFutureDays ? _self.timetableFut as int?,userType: freezed == userType ? _self.userType : userType // ignore: cast_nullable_to_non_nullable as String?,children: null == children ? _self._children : children // ignore: cast_nullable_to_non_nullable as List,loaded: null == loaded ? _self.loaded : loaded // ignore: cast_nullable_to_non_nullable +as bool,loadFailed: null == loadFailed ? _self.loadFailed : loadFailed // ignore: cast_nullable_to_non_nullable as bool, )); } diff --git a/lib/state/app/modules/children/child_selection_cubit.dart b/lib/state/app/modules/children/child_selection_cubit.dart index 4f8de18..919c9dd 100644 --- a/lib/state/app/modules/children/child_selection_cubit.dart +++ b/lib/state/app/modules/children/child_selection_cubit.dart @@ -10,7 +10,7 @@ class ChildSelectionCubit extends HydratedCubit { void select(String childId) => emit(childId); - Future reset() async => emit(null); + void reset() => emit(null); @override String? fromJson(Map json) => json['childId'] as String?; diff --git a/lib/state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart b/lib/state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart index 0e8304f..210da2c 100644 --- a/lib/state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart +++ b/lib/state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart @@ -92,7 +92,7 @@ class NextcloudCapabilitiesCubit } } - Future reset() async => emit(const NextcloudCapabilitiesState()); + void reset() => emit(const NextcloudCapabilitiesState()); @override NextcloudCapabilitiesState fromJson(Map json) { diff --git a/lib/view/login/guardian_login_controller.dart b/lib/view/login/guardian_login_controller.dart index dc6780c..1c681c1 100644 --- a/lib/view/login/guardian_login_controller.dart +++ b/lib/view/login/guardian_login_controller.dart @@ -54,9 +54,14 @@ class GuardianLoginController extends ChangeNotifier { String? get errorMessage => _errorMessage; String? get errorDetails => _errorDetails; - bool canResend() { + bool canResend() => resendCooldown() == Duration.zero; + + /// Restzeit, bis [resend] wieder erlaubt ist. + Duration resendCooldown() { final pending = _pending; - return pending != null && !_now().isBefore(pending.resendAvailableAt); + if (pending == null) return Duration.zero; + final remaining = pending.resendAvailableAt.difference(_now()); + return remaining.isNegative ? Duration.zero : remaining; } /// Picks up a request started before the app was closed. @@ -77,8 +82,12 @@ class GuardianLoginController extends ChangeNotifier { Future requestCode(String email) async { final normalized = email.trim().toLowerCase(); if (DemoMode.matchesGuardian(normalized)) { - await _signIn(GuardianSession(email: normalized, isDemo: true)); - return true; + var signedIn = false; + await _run(() async { + await _signIn(GuardianSession(email: normalized, isDemo: true)); + signedIn = true; + }); + return signedIn; } await _run(() async { final secret = DeviceBinding.generateSecret(); @@ -128,6 +137,17 @@ class GuardianLoginController extends ChangeNotifier { return _complete(linkToken: link.linkToken); } + /// A mail link that does not belong to the server the app talks to (a live + /// link while the app points at beta). Without this the tap would do nothing + /// at all. + void rejectForeignLink() { + _errorMessage = + 'Dieser Anmeldelink gehört zu einem anderen Server als dem, mit dem ' + 'die App gerade verbunden ist. Bitte gib den Code aus der E-Mail ein.'; + _errorDetails = null; + notifyListeners(); + } + /// Abandons the running request, e.g. to correct a mistyped address. Future changeEmail() async { await _store.clear(); @@ -140,7 +160,15 @@ class GuardianLoginController extends ChangeNotifier { Future _complete({String? code, String? linkToken}) async { final pending = _pending; - if (pending == null) return false; + if (pending == null) { + _errorMessage = GuardianLoginException.messageFor( + GuardianLoginError.requestExpired, + ); + _errorDetails = null; + _step = GuardianLoginStep.enterEmail; + notifyListeners(); + return false; + } var signedIn = false; await _run(() async { await _verify.run( @@ -188,9 +216,16 @@ class GuardianLoginController extends ChangeNotifier { } static Future _defaultSignIn(Session session) async { - // Drop any widget snapshot of a previous account before the new one loads. - await WidgetSync.clear(); - await WidgetSync.triggerUpdate(); + // Sign in first: the one-time code is already spent at this point, so a + // failing widget reset must not cost the session (the user would have to + // request a fresh mail for a login that actually succeeded). await SessionManager().signIn(session); + // Drop any widget snapshot of a previous account. + try { + await WidgetSync.clear(); + await WidgetSync.triggerUpdate(); + } on Object catch (e) { + log('Guardian login: widget reset failed: $e'); + } } } diff --git a/lib/view/login/login.dart b/lib/view/login/login.dart index dbdab1d..e5e4e38 100644 --- a/lib/view/login/login.dart +++ b/lib/view/login/login.dart @@ -7,6 +7,7 @@ import '../../api/marianumconnect/marianumconnect_endpoint.dart' as mc; import '../../auth_link/guardian_link_listener.dart'; import '../../auth_link/guardian_login_link.dart'; import '../../background/widget_background_task.dart'; +import '../../session/session_lifecycle.dart'; import '../../state/app/modules/account/bloc/account_bloc.dart'; import '../../state/app/modules/account/bloc/account_state.dart'; import '../../state/app/modules/settings/bloc/settings_cubit.dart'; @@ -14,6 +15,7 @@ import '../../storage/dev_tools_settings.dart'; import '../../storage/settings.dart' as model; import '../../theming/light_app_theme.dart'; import '../../utils/haptics.dart'; +import '../../widget/info_dialog.dart'; import '../pages/settings/widgets/endpoint_picker.dart'; import 'guardian_login_controller.dart'; import 'login_controller.dart'; @@ -54,6 +56,16 @@ class _LoginState extends State with SingleTickerProviderStateMixin { }); GuardianLinkListener.pending.addListener(_consumeGuardianLink); _consumeGuardianLink(); + WidgetsBinding.instance.addPostFrameCallback((_) => _showSignOutNotice()); + } + + /// An involuntary sign-out (expired token, rotated password) otherwise just + /// drops the user here without a word. + void _showSignOutNotice() { + final notice = SessionLifecycle.signOutNotice.value; + if (notice == null || !mounted) return; + SessionLifecycle.signOutNotice.value = null; + InfoDialog.show(context, notice, title: 'Erneut anmelden'); } /// A tapped mail link finishes the guardian login without typing the code. @@ -65,10 +77,13 @@ class _LoginState extends State with SingleTickerProviderStateMixin { uri, apiBase: Uri.parse(mc.MarianumConnectEndpoint.current()), ); - if (link == null) return; await _guardianRestored; if (!mounted) return; setState(() => _audience = LoginAudience.guardian); + if (link == null) { + _guardianController.rejectForeignLink(); + return; + } final signedIn = await _guardianController.submitLink(link); if (signedIn && mounted) _onLoginSuccess(); } diff --git a/lib/view/login/widgets/guardian_login_card.dart b/lib/view/login/widgets/guardian_login_card.dart index b910d63..59f1d1b 100644 --- a/lib/view/login/widgets/guardian_login_card.dart +++ b/lib/view/login/widgets/guardian_login_card.dart @@ -158,7 +158,7 @@ class _GuardianLoginCardState extends State { Widget _buildCodeStep(BuildContext context) { final theme = Theme.of(context); final pending = _controller.pending!; - final remaining = pending.resendAvailableAt.difference(DateTime.now()); + final remaining = _controller.resendCooldown(); return Form( key: _codeFormKey, child: LoginCardFrame( diff --git a/lib/view/login/widgets/login_audience_card.dart b/lib/view/login/widgets/login_audience_card.dart index 0e83ae6..aaae3ba 100644 --- a/lib/view/login/widgets/login_audience_card.dart +++ b/lib/view/login/widgets/login_audience_card.dart @@ -14,7 +14,7 @@ class LoginAudienceCard extends StatelessWidget { @override Widget build(BuildContext context) => LoginCardFrame( title: 'Anmelden', - hint: 'Bite wähle deine Anmeldemethode', + hint: 'Bitte wähle deine Anmeldemethode', children: [ _AudienceButton( key: const Key('login-audience-school'), diff --git a/lib/view/pages/absence_report/absence_report_view.dart b/lib/view/pages/absence_report/absence_report_view.dart index a027c37..a3517ab 100644 --- a/lib/view/pages/absence_report/absence_report_view.dart +++ b/lib/view/pages/absence_report/absence_report_view.dart @@ -106,6 +106,18 @@ class _AbsenceFormState extends State<_AbsenceForm> { // The child's identity is the whole point of the form, so a failed // prefill is an error here, not a degraded start. final prefill = await AbsencePrefill().run(childId: _childId); + // Name and class are readOnly in this mode, so a gap in the prefill + // cannot be filled by the user — say so instead of offering a form that + // can never be submitted. + if (prefill.firstName.trim().isEmpty || + prefill.lastName.trim().isEmpty || + prefill.className.trim().isEmpty) { + throw AbsencePrefillIncompleteException( + technicalDetails: + 'childId=$_childId, class="${prefill.className}", ' + 'name="${prefill.firstName} ${prefill.lastName}"', + ); + } _classes = [prefill.className]; _applyPrefill(prefill, _classes); return; diff --git a/lib/view/pages/parent_letters/parent_letter_form_policy.dart b/lib/view/pages/parent_letters/parent_letter_form_policy.dart index e6eec0c..999de4d 100644 --- a/lib/view/pages/parent_letters/parent_letter_form_policy.dart +++ b/lib/view/pages/parent_letters/parent_letter_form_policy.dart @@ -25,10 +25,17 @@ class ParentLetterFormPolicy { final List fields; final bool signatureRequired; + /// The letter asks for something this app version drops from [fields]. + /// Only a required one blocks the form ([ParentLetterFormMode.unsupported]); + /// an optional one still has to be named, or the response looks complete + /// while a question went unanswered. + final bool hasUnsupportedFields; + const ParentLetterFormPolicy._( this.mode, this.fields, this.signatureRequired, + this.hasUnsupportedFields, ); bool get canSubmit => @@ -89,6 +96,11 @@ class ParentLetterFormPolicy { ? ParentLetterFormMode.change : ParentLetterFormMode.open; } - return ParentLetterFormPolicy._(mode, supported, request.signatureRequired); + return ParentLetterFormPolicy._( + mode, + supported, + request.signatureRequired, + supported.length != request.fields.length, + ); } } diff --git a/lib/view/pages/parent_letters/widgets/parent_letter_response_card.dart b/lib/view/pages/parent_letters/widgets/parent_letter_response_card.dart index 1822990..1b84825 100644 --- a/lib/view/pages/parent_letters/widgets/parent_letter_response_card.dart +++ b/lib/view/pages/parent_letters/widgets/parent_letter_response_card.dart @@ -101,10 +101,14 @@ class _ParentLetterResponseCardState extends State { ParentLetterFormMode.open || ParentLetterFormMode.change => _form(policy), ParentLetterFormMode.done => _result(theme, policy), + // The server locks a response for other reasons too (withdrawn, + // finalised), so only name the deadline when there is one. ParentLetterFormMode.closed => [ _note( - 'Die Frist ist abgelaufen. Eine Rückmeldung ist nicht mehr ' - 'möglich.', + deadline != null + ? 'Die Frist ist abgelaufen. Eine Rückmeldung ist nicht ' + 'mehr möglich.' + : 'Eine Rückmeldung ist nicht mehr möglich.', ), ], ParentLetterFormMode.unsupported => [ @@ -157,6 +161,15 @@ class _ParentLetterResponseCardState extends State { ), ), ], + if (policy.hasUnsupportedFields) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _note( + 'Dieser Elternbrief enthält zusätzlich eine Abfrage, die diese ' + 'App-Version nicht anzeigen kann. Bitte aktualisiere die App, um ' + 'vollständig zu antworten.', + ), + ), Padding( padding: const EdgeInsets.fromLTRB(16, 4, 16, 0), child: AsyncActionButton( diff --git a/lib/view/pages/timetable/timetable.dart b/lib/view/pages/timetable/timetable.dart index 206f110..5179fd8 100644 --- a/lib/view/pages/timetable/timetable.dart +++ b/lib/view/pages/timetable/timetable.dart @@ -12,7 +12,6 @@ import '../../../state/app/modules/timetable/bloc/timetable_state.dart'; import '../../../state/app/modules/timetable/policy/timetable_policy.dart'; import '../../../state/app/modules/timetable/subject/timetable_subject.dart'; import '../../../utils/haptics.dart'; -import '../../../widget/app_progress_indicator.dart'; import '../../../widget/child_switcher.dart'; import '../../../widget/demo_restricted.dart'; import 'custom_events/custom_event_edit_dialog.dart'; @@ -29,6 +28,11 @@ class Timetable extends StatefulWidget { } class _TimetableState extends State { + /// One calendar key per subject: a new subject (child switch, foreign plan) + /// must not inherit the displayed week of the previous one, but coming back + /// to a subject should find its calendar where it was left. + final Map> + _calendarKeys = {}; GlobalKey _calendarKey = GlobalKey(); TimetableSubject? _calendarSubject; @@ -107,7 +111,10 @@ class _TimetableState extends State { // previous calendar state. if (subject != _calendarSubject) { _calendarSubject = subject; - _calendarKey = GlobalKey(); + _calendarKey = _calendarKeys.putIfAbsent( + subject, + GlobalKey.new, + ); } final innerState = context.watch().state.data; final atToday = innerState != null && _isOnInitialWeek(innerState); @@ -204,20 +211,16 @@ class _TimetableState extends State { } /// Shown instead of a plan when the session has none, i.e. a guardian whose -/// children are not known (yet). +/// children are not known (yet). [NoChildrenPlaceholder] tells a pending or +/// failed capability load apart from a confirmed empty list. class _NoTimetableView extends StatelessWidget { const _NoTimetableView(); @override - Widget build(BuildContext context) { - final capabilities = context.watch().state; - return Scaffold( - appBar: AppBar(title: const Text('Stunden & Vertretungsplan')), - body: capabilities.loaded - ? const NoChildrenPlaceholder() - : const Center(child: AppProgressIndicator.large()), - ); - } + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text('Stunden & Vertretungsplan')), + body: const NoChildrenPlaceholder(), + ); } /// Slim banner shown at the top of the timetable while a foreign element's plan diff --git a/lib/widget/child_switcher.dart b/lib/widget/child_switcher.dart index af9f809..c2d6e54 100644 --- a/lib/widget/child_switcher.dart +++ b/lib/widget/child_switcher.dart @@ -5,6 +5,8 @@ import '../api/marianumconnect/queries/get_capabilities/guardian_child.dart'; import '../state/app/modules/capabilities/bloc/capabilities_cubit.dart'; import '../state/app/modules/children/child_selection_cubit.dart'; import '../utils/haptics.dart'; +import 'app_progress_indicator.dart'; +import 'async_action_button.dart'; import 'centered_leading.dart'; import 'details_bottom_sheet.dart'; import 'placeholder_view.dart'; @@ -71,15 +73,37 @@ class ChildTile extends StatelessWidget { ); } -/// Shown by per-child modules while a guardian has no linked child. +/// 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 +/// over a network error. class NoChildrenPlaceholder extends StatelessWidget { const NoChildrenPlaceholder({super.key}); @override - Widget build(BuildContext context) => const PlaceholderView( - icon: Icons.family_restroom_outlined, - text: - 'Deinem Konto ist noch kein Kind zugeordnet. Bitte wende dich an ' - 'das Sekretariat.', - ); + Widget build(BuildContext context) { + final capabilities = context.watch().state; + if (!capabilities.loaded) { + return const Center(child: AppProgressIndicator.large()); + } + if (capabilities.loadFailed) { + return PlaceholderView( + icon: Icons.cloud_off_outlined, + text: + 'Die zugeordneten Kinder konnten nicht geladen werden. Bitte ' + 'prüfe deine Internetverbindung.', + button: AsyncActionButton( + icon: Icons.refresh, + onPressed: context.read().load, + child: const Text('Erneut versuchen'), + ), + ); + } + return const PlaceholderView( + icon: Icons.family_restroom_outlined, + text: + 'Deinem Konto ist noch kein Kind zugeordnet. Bitte wende dich an ' + 'das Sekretariat.', + ); + } } diff --git a/test/view/login/guardian_login_controller_test.dart b/test/view/login/guardian_login_controller_test.dart index 93f22be..d7a39eb 100644 --- a/test/view/login/guardian_login_controller_test.dart +++ b/test/view/login/guardian_login_controller_test.dart @@ -220,6 +220,45 @@ void main() { expect(signedIn.single.isDemo, isTrue); }); + test('a failing demo sign-in reports instead of throwing', () async { + final c = GuardianLoginController( + request: request, + verify: verify, + store: store, + signIn: (_) async => throw Exception('keystore unavailable'), + tokenName: () async => 'test', + now: () => now, + ); + expect(await c.requestCode(DemoMode.guardianEmail), isFalse); + expect(c.errorMessage, isNotNull); + expect(c.loading, isFalse); + }); + + test('a code without a running request reports instead of failing ' + 'silently', () async { + final c = controller(); + expect(await c.submitCode('123456'), isFalse); + expect(c.errorMessage, isNotNull); + expect(c.step, GuardianLoginStep.enterEmail); + expect(verify.lastCall, isNull); + }); + + test('a link of another server is named as such', () { + final c = controller(); + c.rejectForeignLink(); + expect(c.errorMessage, contains('anderen Server')); + }); + + test('the resend cooldown runs on the injected clock', () async { + final c = controller(); + await c.requestCode('e@x.de'); + expect(c.resendCooldown(), const Duration(seconds: 60)); + expect(c.canResend(), isFalse); + now = now.add(const Duration(seconds: 61)); + expect(c.resendCooldown(), Duration.zero); + expect(c.canResend(), isTrue); + }); + group('GuardianLoginException.fromDio', () { DioException failure(int status, Object? body) => DioException( requestOptions: RequestOptions(), @@ -291,6 +330,17 @@ void main() { expect(e.error, GuardianLoginError.accountDisabled); }); + test('a bare 401 on the e-mail step is not a wrong code', () { + expect( + GuardianLoginException.fromDio(failure(401, null), verifying: false), + isNot(isA()), + ); + final verifying = + GuardianLoginException.fromDio(failure(401, null)) + as GuardianLoginException; + expect(verifying.error, GuardianLoginError.invalidCode); + }); + test('server errors keep the generic mapping', () { expect( GuardianLoginException.fromDio(failure(500, 'boom')), diff --git a/test/view/parent_letters/parent_letter_form_policy_test.dart b/test/view/parent_letters/parent_letter_form_policy_test.dart index cef4306..d28f7e9 100644 --- a/test/view/parent_letters/parent_letter_form_policy_test.dart +++ b/test/view/parent_letters/parent_letter_form_policy_test.dart @@ -75,13 +75,22 @@ void main() { expect(policy.mode, ParentLetterFormMode.done); }); - test('unknown optional fields are skipped', () { + test('unknown optional fields are skipped but flagged', () { final policy = ParentLetterFormPolicy.resolve( request: const ParentLetterRequest(fields: [_choice, _unknownOptional]), child: _child(), )!; expect(policy.mode, ParentLetterFormMode.open); expect(policy.fields, [_choice]); + expect(policy.hasUnsupportedFields, isTrue); + }); + + test('a fully supported request is not flagged', () { + final policy = ParentLetterFormPolicy.resolve( + request: const ParentLetterRequest(fields: [_choice, _optionalChoice]), + child: _child(), + )!; + expect(policy.hasUnsupportedFields, isFalse); }); });