fixed guardian login edge cases and misleading placeholders on failed loads
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
+3
-3
@@ -573,14 +573,14 @@ Future<void> _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();
|
||||
|
||||
@@ -141,6 +141,10 @@ Future<void> _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<void>? permissionRequest;
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: ConfirmDialog(
|
||||
@@ -150,9 +154,10 @@ Future<void> _maybePrompt(
|
||||
confirmButton: 'Weiter',
|
||||
cancelButton: null,
|
||||
onConfirm: () =>
|
||||
unawaited(_requestPermission(context, settings, prompt)),
|
||||
permissionRequest = _requestPermission(context, settings, prompt),
|
||||
).build,
|
||||
);
|
||||
await permissionRequest;
|
||||
} finally {
|
||||
_promptInFlight = false;
|
||||
}
|
||||
|
||||
@@ -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<String?> 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<void> signOut() async {
|
||||
static Future<void> signOut({String? notice}) async {
|
||||
signOutNotice.value = notice;
|
||||
try {
|
||||
await PushRegistration().logoutCleanup();
|
||||
} on Object catch (e) {
|
||||
|
||||
@@ -25,8 +25,9 @@ class CapabilitiesCubit extends HydratedCubit<CapabilitiesState> {
|
||||
|
||||
/// 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<void> load() async {
|
||||
if (DemoMode.active) {
|
||||
emit(
|
||||
@@ -51,11 +52,11 @@ class CapabilitiesCubit extends HydratedCubit<CapabilitiesState> {
|
||||
);
|
||||
} catch (e) {
|
||||
log('Failed to load capabilities: $e');
|
||||
emit(state.copyWith(loaded: true));
|
||||
emit(state.copyWith(loaded: true, loadFailed: true));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> reset() async => emit(const CapabilitiesState());
|
||||
void reset() => emit(const CapabilitiesState());
|
||||
|
||||
@override
|
||||
CapabilitiesState fromJson(Map<String, dynamic> json) {
|
||||
|
||||
@@ -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<String, Object?> json) =>
|
||||
|
||||
@@ -16,7 +16,7 @@ T _$identity<T>(T value) => value;
|
||||
/// @nodoc
|
||||
mixin _$CapabilitiesState {
|
||||
|
||||
bool get viewForeignTimetables; bool get pushNotifications; int? get timetablePastDays; int? get timetableFutureDays; String? get userType; List<GuardianChild> get children; bool get loaded;
|
||||
bool get viewForeignTimetables; bool get pushNotifications; int? get timetablePastDays; int? get timetableFutureDays; String? get userType; List<GuardianChild> 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<CapabilitiesState> 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<GuardianChild> children, bool loaded
|
||||
bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, List<GuardianChild> 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<GuardianChild>,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 extends Object?>(TResult Function( bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, List<GuardianChild> children, bool loaded)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, List<GuardianChild> 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 extends Object?>(TResult Function( bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, List<GuardianChild> children, bool loaded) $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, List<GuardianChild> 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 extends Object?>(TResult? Function( bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, List<GuardianChild> children, bool loaded)? $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, List<GuardianChild> 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<GuardianChild> children = const <GuardianChild>[], this.loaded = false}): _children = children,super._();
|
||||
const _CapabilitiesState({this.viewForeignTimetables = false, this.pushNotifications = false, this.timetablePastDays, this.timetableFutureDays, this.userType, List<GuardianChild> children = const <GuardianChild>[], this.loaded = false, @JsonKey(includeToJson: false, includeFromJson: false) this.loadFailed = false}): _children = children,super._();
|
||||
factory _CapabilitiesState.fromJson(Map<String, dynamic> 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<String, dynamic> 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<GuardianChild> children, bool loaded
|
||||
bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, List<GuardianChild> 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<GuardianChild>,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,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ class ChildSelectionCubit extends HydratedCubit<String?> {
|
||||
|
||||
void select(String childId) => emit(childId);
|
||||
|
||||
Future<void> reset() async => emit(null);
|
||||
void reset() => emit(null);
|
||||
|
||||
@override
|
||||
String? fromJson(Map<String, dynamic> json) => json['childId'] as String?;
|
||||
|
||||
@@ -92,7 +92,7 @@ class NextcloudCapabilitiesCubit
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> reset() async => emit(const NextcloudCapabilitiesState());
|
||||
void reset() => emit(const NextcloudCapabilitiesState());
|
||||
|
||||
@override
|
||||
NextcloudCapabilitiesState fromJson(Map<String, dynamic> json) {
|
||||
|
||||
@@ -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<bool> requestCode(String email) async {
|
||||
final normalized = email.trim().toLowerCase();
|
||||
if (DemoMode.matchesGuardian(normalized)) {
|
||||
var signedIn = false;
|
||||
await _run(() async {
|
||||
await _signIn(GuardianSession(email: normalized, isDemo: true));
|
||||
return 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<void> changeEmail() async {
|
||||
await _store.clear();
|
||||
@@ -140,7 +160,15 @@ class GuardianLoginController extends ChangeNotifier {
|
||||
|
||||
Future<bool> _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<void> _defaultSignIn(Session session) async {
|
||||
// Drop any widget snapshot of a previous account before the new one loads.
|
||||
// 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();
|
||||
await SessionManager().signIn(session);
|
||||
} on Object catch (e) {
|
||||
log('Guardian login: widget reset failed: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Login> 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<Login> 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();
|
||||
}
|
||||
|
||||
@@ -158,7 +158,7 @@ class _GuardianLoginCardState extends State<GuardianLoginCard> {
|
||||
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(
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -25,10 +25,17 @@ class ParentLetterFormPolicy {
|
||||
final List<ParentLetterField> 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,10 +101,14 @@ class _ParentLetterResponseCardState extends State<ParentLetterResponseCard> {
|
||||
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<ParentLetterResponseCard> {
|
||||
),
|
||||
),
|
||||
],
|
||||
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(
|
||||
|
||||
@@ -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<Timetable> {
|
||||
/// 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<TimetableSubject, GlobalKey<TimetableCalendarViewState>>
|
||||
_calendarKeys = {};
|
||||
GlobalKey<TimetableCalendarViewState> _calendarKey =
|
||||
GlobalKey<TimetableCalendarViewState>();
|
||||
TimetableSubject? _calendarSubject;
|
||||
@@ -107,7 +111,10 @@ class _TimetableState extends State<Timetable> {
|
||||
// previous calendar state.
|
||||
if (subject != _calendarSubject) {
|
||||
_calendarSubject = subject;
|
||||
_calendarKey = GlobalKey<TimetableCalendarViewState>();
|
||||
_calendarKey = _calendarKeys.putIfAbsent(
|
||||
subject,
|
||||
GlobalKey<TimetableCalendarViewState>.new,
|
||||
);
|
||||
}
|
||||
final innerState = context.watch<B>().state.data;
|
||||
final atToday = innerState != null && _isOnInitialWeek(innerState);
|
||||
@@ -204,20 +211,16 @@ class _TimetableState extends State<Timetable> {
|
||||
}
|
||||
|
||||
/// 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<CapabilitiesCubit>().state;
|
||||
return Scaffold(
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(title: const Text('Stunden & Vertretungsplan')),
|
||||
body: capabilities.loaded
|
||||
? const NoChildrenPlaceholder()
|
||||
: const Center(child: AppProgressIndicator.large()),
|
||||
body: const NoChildrenPlaceholder(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Slim banner shown at the top of the timetable while a foreign element's plan
|
||||
|
||||
@@ -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(
|
||||
Widget build(BuildContext context) {
|
||||
final capabilities = context.watch<CapabilitiesCubit>().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<CapabilitiesCubit>().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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<GuardianLoginException>()),
|
||||
);
|
||||
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')),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user