fixed guardian login edge cases and misleading placeholders on failed loads
This commit is contained in:
@@ -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)) {
|
||||
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<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.
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user