added guardian login with views for their assigned childs
This commit is contained in:
@@ -2,7 +2,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../model/account_data.dart';
|
||||
import '../../session/session_manager.dart';
|
||||
import '../../theming/light_app_theme.dart';
|
||||
import '../../widget/app_progress_indicator.dart';
|
||||
|
||||
@@ -61,7 +61,7 @@ class _AccountLoadingScreenState extends State<AccountLoadingScreen> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: AccountData().abandonLoad,
|
||||
onPressed: SessionManager().abandonLoad,
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.white),
|
||||
child: const Text('Zur Anmeldung'),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../api/demo/demo_mode.dart';
|
||||
import '../../api/errors/error_mapper.dart';
|
||||
import '../../api/marianumconnect/auth/device_token_name.dart';
|
||||
import '../../api/marianumconnect/queries/auth_guardian/auth_guardian_request.dart';
|
||||
import '../../api/marianumconnect/queries/auth_guardian/auth_guardian_verify.dart';
|
||||
import '../../api/marianumconnect/queries/auth_guardian/guardian_login_exception.dart';
|
||||
import '../../auth_link/device_binding.dart';
|
||||
import '../../auth_link/guardian_login_link.dart';
|
||||
import '../../auth_link/pending_guardian_request.dart';
|
||||
import '../../session/session.dart';
|
||||
import '../../session/session_manager.dart';
|
||||
import '../../widget_data/widget_sync.dart';
|
||||
|
||||
enum GuardianLoginStep { enterEmail, enterCode }
|
||||
|
||||
/// Drives the passwordless guardian login: request a mail, then finish with
|
||||
/// the mailed code or link. The running request is persisted so the flow
|
||||
/// survives the app being killed while the user reads the mail.
|
||||
class GuardianLoginController extends ChangeNotifier {
|
||||
final AuthGuardianRequest _request;
|
||||
final AuthGuardianVerify _verify;
|
||||
final PendingGuardianRequestStore _store;
|
||||
final Future<void> Function(Session session) _signIn;
|
||||
final Future<String> Function() _tokenName;
|
||||
final DateTime Function() _now;
|
||||
|
||||
GuardianLoginController({
|
||||
AuthGuardianRequest? request,
|
||||
AuthGuardianVerify? verify,
|
||||
PendingGuardianRequestStore store = const PendingGuardianRequestStore(),
|
||||
Future<void> Function(Session session)? signIn,
|
||||
Future<String> Function()? tokenName,
|
||||
DateTime Function()? now,
|
||||
}) : _request = request ?? AuthGuardianRequest(),
|
||||
_verify = verify ?? AuthGuardianVerify(),
|
||||
_store = store,
|
||||
_signIn = signIn ?? _defaultSignIn,
|
||||
_tokenName = tokenName ?? DeviceTokenName.resolve,
|
||||
_now = now ?? DateTime.now;
|
||||
|
||||
GuardianLoginStep _step = GuardianLoginStep.enterEmail;
|
||||
PendingGuardianRequest? _pending;
|
||||
bool _loading = false;
|
||||
String? _errorMessage;
|
||||
String? _errorDetails;
|
||||
|
||||
GuardianLoginStep get step => _step;
|
||||
PendingGuardianRequest? get pending => _pending;
|
||||
bool get loading => _loading;
|
||||
String? get errorMessage => _errorMessage;
|
||||
String? get errorDetails => _errorDetails;
|
||||
|
||||
bool canResend() {
|
||||
final pending = _pending;
|
||||
return pending != null && !_now().isBefore(pending.resendAvailableAt);
|
||||
}
|
||||
|
||||
/// Picks up a request started before the app was closed.
|
||||
Future<void> restore() async {
|
||||
final stored = await _store.read();
|
||||
if (stored == null) return;
|
||||
if (stored.isExpired(_now())) {
|
||||
await _store.clear();
|
||||
return;
|
||||
}
|
||||
_pending = stored;
|
||||
_step = GuardianLoginStep.enterCode;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Sends the login mail. Returns true when the user is already signed in
|
||||
/// (demo address), false when the code step follows or the request failed.
|
||||
Future<bool> requestCode(String email) async {
|
||||
final normalized = email.trim().toLowerCase();
|
||||
if (DemoMode.matchesGuardian(normalized)) {
|
||||
await _signIn(GuardianSession(email: normalized, isDemo: true));
|
||||
return true;
|
||||
}
|
||||
await _run(() async {
|
||||
final secret = DeviceBinding.generateSecret();
|
||||
final response = await _request.run(
|
||||
email: normalized,
|
||||
deviceChallenge: DeviceBinding.challengeFor(secret),
|
||||
tokenName: await _tokenName(),
|
||||
);
|
||||
final pending = PendingGuardianRequest(
|
||||
requestId: response.requestId,
|
||||
email: normalized,
|
||||
deviceSecret: secret,
|
||||
expiresAt: response.expiresAt,
|
||||
resendAvailableAt: response.resendAvailableAt,
|
||||
codeLength: response.codeLength,
|
||||
);
|
||||
await _store.write(pending);
|
||||
_pending = pending;
|
||||
_step = GuardianLoginStep.enterCode;
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> resend() async {
|
||||
final pending = _pending;
|
||||
if (pending == null || !canResend()) return;
|
||||
await requestCode(pending.email);
|
||||
}
|
||||
|
||||
/// Mail clients and autofill may insert spaces into the code.
|
||||
static String normalizeCode(String code) =>
|
||||
code.replaceAll(RegExp(r'\s'), '');
|
||||
|
||||
Future<bool> submitCode(String code) => _complete(code: normalizeCode(code));
|
||||
|
||||
/// Completes the login from a mail link. A link belonging to another
|
||||
/// request (other device, or an older mail) cannot be verified here.
|
||||
Future<bool> submitLink(GuardianLoginLink link) {
|
||||
if (_pending?.requestId != link.requestId) {
|
||||
_errorMessage = GuardianLoginException.messageFor(
|
||||
GuardianLoginError.deviceMismatch,
|
||||
);
|
||||
_errorDetails = null;
|
||||
notifyListeners();
|
||||
return Future.value(false);
|
||||
}
|
||||
return _complete(linkToken: link.linkToken);
|
||||
}
|
||||
|
||||
/// Abandons the running request, e.g. to correct a mistyped address.
|
||||
Future<void> changeEmail() async {
|
||||
await _store.clear();
|
||||
_pending = null;
|
||||
_step = GuardianLoginStep.enterEmail;
|
||||
_errorMessage = null;
|
||||
_errorDetails = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<bool> _complete({String? code, String? linkToken}) async {
|
||||
final pending = _pending;
|
||||
if (pending == null) return false;
|
||||
var signedIn = false;
|
||||
await _run(() async {
|
||||
await _verify.run(
|
||||
requestId: pending.requestId,
|
||||
deviceVerifier: pending.deviceSecret,
|
||||
tokenName: await _tokenName(),
|
||||
code: code,
|
||||
linkToken: linkToken,
|
||||
);
|
||||
await _store.clear();
|
||||
await _signIn(GuardianSession(email: pending.email));
|
||||
signedIn = true;
|
||||
});
|
||||
return signedIn;
|
||||
}
|
||||
|
||||
Future<void> _run(Future<void> Function() body) async {
|
||||
if (_loading) return;
|
||||
_loading = true;
|
||||
_errorMessage = null;
|
||||
_errorDetails = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
await body();
|
||||
} on GuardianLoginException catch (e) {
|
||||
_errorMessage = e.userMessage;
|
||||
_errorDetails = e.technicalDetails;
|
||||
// These end the request for good; only a new mail helps.
|
||||
if (e.error
|
||||
case GuardianLoginError.requestExpired ||
|
||||
GuardianLoginError.requestConsumed ||
|
||||
GuardianLoginError.tooManyAttempts) {
|
||||
await _store.clear();
|
||||
_pending = null;
|
||||
_step = GuardianLoginStep.enterEmail;
|
||||
}
|
||||
} catch (e) {
|
||||
log('Guardian login failed: $e');
|
||||
_errorMessage = errorToUserMessage(e);
|
||||
_errorDetails = errorToTechnicalDetails(e);
|
||||
} finally {
|
||||
_loading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
await SessionManager().signIn(session);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,9 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
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 '../../state/app/modules/account/bloc/account_bloc.dart';
|
||||
import '../../state/app/modules/account/bloc/account_state.dart';
|
||||
@@ -12,8 +15,11 @@ import '../../storage/settings.dart' as model;
|
||||
import '../../theming/light_app_theme.dart';
|
||||
import '../../utils/haptics.dart';
|
||||
import '../pages/settings/widgets/endpoint_picker.dart';
|
||||
import 'guardian_login_controller.dart';
|
||||
import 'login_controller.dart';
|
||||
import 'post_login_splash.dart';
|
||||
import 'widgets/guardian_login_card.dart';
|
||||
import 'widgets/login_audience_card.dart';
|
||||
import 'widgets/login_branding.dart';
|
||||
import 'widgets/login_card.dart';
|
||||
|
||||
@@ -28,12 +34,45 @@ class _LoginState extends State<Login> with SingleTickerProviderStateMixin {
|
||||
static const _marianumRed = LightAppTheme.marianumRed;
|
||||
|
||||
final LoginController _controller = LoginController();
|
||||
final GuardianLoginController _guardianController = GuardianLoginController();
|
||||
late final Future<void> _guardianRestored;
|
||||
|
||||
/// Null while the user has not picked who is signing in.
|
||||
LoginAudience? _audience;
|
||||
late final AnimationController _fade = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 450),
|
||||
value: 1,
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_guardianRestored = _guardianController.restore().then((_) {
|
||||
if (!mounted || _guardianController.pending == null) return;
|
||||
setState(() => _audience = LoginAudience.guardian);
|
||||
});
|
||||
GuardianLinkListener.pending.addListener(_consumeGuardianLink);
|
||||
_consumeGuardianLink();
|
||||
}
|
||||
|
||||
/// A tapped mail link finishes the guardian login without typing the code.
|
||||
Future<void> _consumeGuardianLink() async {
|
||||
final uri = GuardianLinkListener.pending.value;
|
||||
if (uri == null) return;
|
||||
GuardianLinkListener.clear();
|
||||
final link = GuardianLoginLink.parse(
|
||||
uri,
|
||||
apiBase: Uri.parse(mc.MarianumConnectEndpoint.current()),
|
||||
);
|
||||
if (link == null) return;
|
||||
await _guardianRestored;
|
||||
if (!mounted) return;
|
||||
setState(() => _audience = LoginAudience.guardian);
|
||||
final signedIn = await _guardianController.submitLink(link);
|
||||
if (signedIn && mounted) _onLoginSuccess();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
@@ -43,8 +82,10 @@ class _LoginState extends State<Login> with SingleTickerProviderStateMixin {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
GuardianLinkListener.pending.removeListener(_consumeGuardianLink);
|
||||
_fade.dispose();
|
||||
_controller.dispose();
|
||||
_guardianController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -64,6 +105,20 @@ class _LoginState extends State<Login> with SingleTickerProviderStateMixin {
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildCard() => switch (_audience) {
|
||||
null => LoginAudienceCard(
|
||||
onSelected: (choice) => setState(() => _audience = choice),
|
||||
),
|
||||
LoginAudience.school => LoginCard(
|
||||
controller: _controller,
|
||||
onSuccess: _onLoginSuccess,
|
||||
),
|
||||
LoginAudience.guardian => GuardianLoginCard(
|
||||
controller: _guardianController,
|
||||
onSuccess: _onLoginSuccess,
|
||||
),
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
backgroundColor: _marianumRed,
|
||||
@@ -89,12 +144,33 @@ class _LoginState extends State<Login> with SingleTickerProviderStateMixin {
|
||||
children: [
|
||||
const LoginHeader(),
|
||||
const SizedBox(height: 28),
|
||||
LoginCard(
|
||||
controller: _controller,
|
||||
onSuccess: _onLoginSuccess,
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
const LoginDisclaimer(),
|
||||
_buildCard(),
|
||||
if (_audience != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
// Leaving mid-request would drop the form that
|
||||
// receives the result, so it is disabled then.
|
||||
child: ListenableBuilder(
|
||||
listenable: Listenable.merge([
|
||||
_controller,
|
||||
_guardianController,
|
||||
]),
|
||||
builder: (context, _) => TextButton.icon(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
icon: const Icon(Icons.arrow_back, size: 18),
|
||||
label: const Text('Zurück zur Auswahl'),
|
||||
onPressed:
|
||||
_controller.loading ||
|
||||
_guardianController.loading
|
||||
? null
|
||||
: () => setState(() => _audience = null),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
),
|
||||
const Column(
|
||||
|
||||
@@ -10,7 +10,8 @@ import '../../api/marianumconnect/auth/device_token_name.dart';
|
||||
import '../../api/marianumconnect/auth/token_storage.dart';
|
||||
import '../../api/marianumconnect/queries/auth_login/auth_login.dart';
|
||||
import '../../api/marianumconnect/queries/auth_logout/auth_logout.dart';
|
||||
import '../../model/account_data.dart';
|
||||
import '../../session/session.dart';
|
||||
import '../../session/session_manager.dart';
|
||||
import '../../widget_data/widget_sync.dart';
|
||||
|
||||
/// Outcome of a login attempt.
|
||||
@@ -51,25 +52,17 @@ class LoginController extends ChangeNotifier {
|
||||
// Demo login: the prefix enters local demo mode, password ignored, no
|
||||
// network (see DemoMode).
|
||||
if (DemoMode.matches(user)) {
|
||||
await AccountData().removeData();
|
||||
await const MarianumConnectTokenStorage().clear();
|
||||
await WidgetSync.clear();
|
||||
await WidgetSync.triggerUpdate();
|
||||
await AccountData().setDemo(user);
|
||||
await _discardPreviousAccount();
|
||||
await SessionManager().signIn(
|
||||
CredentialSession(username: user, password: 'demo', isDemo: true),
|
||||
);
|
||||
_loading = false;
|
||||
notifyListeners();
|
||||
return LoginResult.success;
|
||||
}
|
||||
|
||||
try {
|
||||
await AccountData().removeData();
|
||||
// Vorherigen Token revoken bevor wir einen neuen anfordern — ein altes
|
||||
// Account hätte sonst noch einen aktiven Token in api_tokens.
|
||||
await const MarianumConnectTokenStorage().clear();
|
||||
// Widget-Snapshot löschen, sonst blitzt nach Account-Wechsel kurz der
|
||||
// Stundenplan des vorigen Users auf dem Home-Bildschirm.
|
||||
await WidgetSync.clear();
|
||||
await WidgetSync.triggerUpdate();
|
||||
await _discardPreviousAccount();
|
||||
// AuthLogin = Credential-Probe + Token-Create in einem Call.
|
||||
// 401 hier heißt: falsches Passwort.
|
||||
await AuthLogin().run(
|
||||
@@ -77,7 +70,9 @@ class LoginController extends ChangeNotifier {
|
||||
password: password,
|
||||
tokenName: await DeviceTokenName.resolve(),
|
||||
);
|
||||
await AccountData().setData(user, password);
|
||||
await SessionManager().signIn(
|
||||
CredentialSession(username: user, password: password),
|
||||
);
|
||||
// Mint the Nextcloud app password now — it doubles as the Nextcloud
|
||||
// credential probe: a rejection means 2FA is active (or the NC password
|
||||
// diverges) and the login must finish interactively in the browser.
|
||||
@@ -87,7 +82,7 @@ class LoginController extends ChangeNotifier {
|
||||
return ncReady ? LoginResult.success : LoginResult.nextcloudLoginRequired;
|
||||
} catch (e) {
|
||||
log(e.toString());
|
||||
await AccountData().removeData();
|
||||
await SessionManager().signOut();
|
||||
await const MarianumConnectTokenStorage().clear();
|
||||
final isWrongCredentials = e is AuthException && e.statusCode == 401;
|
||||
_errorMessage = isWrongCredentials
|
||||
@@ -100,6 +95,16 @@ class LoginController extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Vorherigen Token verwerfen, bevor ein neuer angefordert wird, und den
|
||||
/// Widget-Snapshot löschen — sonst blitzt nach einem Account-Wechsel kurz
|
||||
/// der Stundenplan des vorigen Users auf dem Home-Bildschirm. Die Session
|
||||
/// selbst überschreibt signIn vollständig.
|
||||
Future<void> _discardPreviousAccount() async {
|
||||
await const MarianumConnectTokenStorage().clear();
|
||||
await WidgetSync.clear();
|
||||
await WidgetSync.triggerUpdate();
|
||||
}
|
||||
|
||||
/// Tries to mint the Nextcloud app password with the just-verified password.
|
||||
/// `false` = Nextcloud rejected the credentials → Login Flow v2 required.
|
||||
/// Transport/server problems stay non-blocking (like the previous
|
||||
@@ -107,7 +112,7 @@ class LoginController extends ChangeNotifier {
|
||||
Future<bool> _prepareNextcloudAppPassword() async {
|
||||
try {
|
||||
final appPassword = await GetAppPassword().run();
|
||||
await AccountData().setAppPassword(appPassword);
|
||||
await SessionManager().setAppPassword(appPassword);
|
||||
return true;
|
||||
} on AuthException {
|
||||
return false;
|
||||
@@ -126,7 +131,7 @@ class LoginController extends ChangeNotifier {
|
||||
} on Object catch (e) {
|
||||
log('Login rollback: MC logout failed: $e');
|
||||
}
|
||||
await AccountData().removeData();
|
||||
await SessionManager().signOut();
|
||||
await const MarianumConnectTokenStorage().clear();
|
||||
_errorMessage =
|
||||
'Die Anmeldung wurde abgebrochen — dein Konto erfordert die Bestätigung im Browser.';
|
||||
|
||||
@@ -7,7 +7,7 @@ import 'package:flutter/material.dart';
|
||||
import '../../api/errors/error_mapper.dart';
|
||||
import '../../api/marianumcloud/app_password/delete_app_password.dart';
|
||||
import '../../api/marianumcloud/login_flow/login_flow_api.dart';
|
||||
import '../../model/account_data.dart';
|
||||
import '../../session/session_manager.dart';
|
||||
import '../../utils/url_opener.dart';
|
||||
import '../../widget/app_progress_indicator.dart';
|
||||
|
||||
@@ -19,7 +19,7 @@ enum _FlowStep { primary, talk }
|
||||
|
||||
/// Runs the Nextcloud Login Flow v2: opens the browser login, polls until the
|
||||
/// user confirmed it there (2FA happens inside the browser) and adopts the
|
||||
/// returned app password via [AccountData.setLoginFlow]. A second, skippable
|
||||
/// returned app password via [SessionManager.setLoginFlow]. A second, skippable
|
||||
/// pass mints the Talk app password so flow accounts keep BOTH push
|
||||
/// subscriptions (see PushRegistrationType). Pops `true` once the primary
|
||||
/// credential was adopted, `false`/`null` when the user backs out before that.
|
||||
@@ -104,7 +104,7 @@ class _NextcloudLoginFlowPageState extends State<NextcloudLoginFlowPage>
|
||||
final credentials = await _api.poll(flow);
|
||||
if (credentials == null || _finished || !mounted) return;
|
||||
if (!LoginFlowApi.loginNameMatches(
|
||||
expected: AccountData().getUsername(),
|
||||
expected: SessionManager().requireNextcloud().username,
|
||||
actual: credentials.loginName,
|
||||
)) {
|
||||
_timer?.cancel();
|
||||
@@ -120,7 +120,7 @@ class _NextcloudLoginFlowPageState extends State<NextcloudLoginFlowPage>
|
||||
}
|
||||
switch (_step) {
|
||||
case _FlowStep.primary:
|
||||
await AccountData().setLoginFlow(credentials.appPassword);
|
||||
await SessionManager().setLoginFlow(credentials.appPassword);
|
||||
if (!mounted) return;
|
||||
// Zweite Freigabe direkt anstoßen: die Browser-Session besteht
|
||||
// bereits, es fehlt nur noch der Grant-Tipp.
|
||||
@@ -129,7 +129,7 @@ class _NextcloudLoginFlowPageState extends State<NextcloudLoginFlowPage>
|
||||
case _FlowStep.talk:
|
||||
_finished = true;
|
||||
_timer?.cancel();
|
||||
await AccountData().setAppPasswordTalk(credentials.appPassword);
|
||||
await SessionManager().setAppPasswordTalk(credentials.appPassword);
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../guardian_login_controller.dart';
|
||||
import 'login_error_banner.dart';
|
||||
import 'login_form_parts.dart';
|
||||
|
||||
/// Passwordless guardian login: e-mail step, then the mailed six-digit code.
|
||||
/// A tapped mail link completes the second step without typing (handled by
|
||||
/// the login screen).
|
||||
class GuardianLoginCard extends StatefulWidget {
|
||||
final GuardianLoginController controller;
|
||||
final VoidCallback onSuccess;
|
||||
|
||||
const GuardianLoginCard({
|
||||
required this.controller,
|
||||
required this.onSuccess,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<GuardianLoginCard> createState() => _GuardianLoginCardState();
|
||||
}
|
||||
|
||||
class _GuardianLoginCardState extends State<GuardianLoginCard> {
|
||||
final _emailFormKey = GlobalKey<FormState>();
|
||||
final _codeFormKey = GlobalKey<FormState>();
|
||||
final _emailController = TextEditingController();
|
||||
final _codeController = TextEditingController();
|
||||
Timer? _resendTicker;
|
||||
|
||||
GuardianLoginController get _controller => widget.controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller.addListener(_onControllerChange);
|
||||
_syncResendTicker();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.removeListener(_onControllerChange);
|
||||
_resendTicker?.cancel();
|
||||
_emailController.dispose();
|
||||
_codeController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onControllerChange() {
|
||||
if (!mounted) return;
|
||||
_syncResendTicker();
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
// Rebuilds once per second while the resend cooldown runs so the countdown
|
||||
// stays current.
|
||||
void _syncResendTicker() {
|
||||
final waiting =
|
||||
_controller.step == GuardianLoginStep.enterCode &&
|
||||
!_controller.canResend();
|
||||
if (waiting && _resendTicker == null) {
|
||||
_resendTicker = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (!mounted) return;
|
||||
setState(() {});
|
||||
if (_controller.canResend()) {
|
||||
_resendTicker?.cancel();
|
||||
_resendTicker = null;
|
||||
}
|
||||
});
|
||||
} else if (!waiting) {
|
||||
_resendTicker?.cancel();
|
||||
_resendTicker = null;
|
||||
}
|
||||
}
|
||||
|
||||
String? _validateEmail(String? value) {
|
||||
final email = (value ?? '').trim();
|
||||
if (email.isEmpty) return 'Eingabe erforderlich';
|
||||
if (!RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$').hasMatch(email)) {
|
||||
return 'Bitte eine gültige E-Mail-Adresse eingeben';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _validateCode(String? value) {
|
||||
final length = _controller.pending!.codeLength;
|
||||
final code = GuardianLoginController.normalizeCode(value ?? '');
|
||||
return code.length == length
|
||||
? null
|
||||
: 'Bitte den $length-stelligen Code eingeben';
|
||||
}
|
||||
|
||||
Future<void> _requestCode() async {
|
||||
if (_controller.loading) return;
|
||||
if (!(_emailFormKey.currentState?.validate() ?? false)) return;
|
||||
final signedIn = await _controller.requestCode(_emailController.text);
|
||||
if (signedIn && mounted) widget.onSuccess();
|
||||
}
|
||||
|
||||
Future<void> _submitCode() async {
|
||||
if (_controller.loading) return;
|
||||
if (!(_codeFormKey.currentState?.validate() ?? false)) return;
|
||||
final signedIn = await _controller.submitCode(_codeController.text);
|
||||
if (signedIn && mounted) widget.onSuccess();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => switch (_controller.step) {
|
||||
GuardianLoginStep.enterEmail => _buildEmailStep(context),
|
||||
GuardianLoginStep.enterCode => _buildCodeStep(context),
|
||||
};
|
||||
|
||||
Widget _buildEmailStep(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Form(
|
||||
key: _emailFormKey,
|
||||
child: LoginCardFrame(
|
||||
title: 'Anmeldung für Eltern',
|
||||
hint:
|
||||
'Gib die E-Mail-Adresse ein, die bei der Schule hinterlegt ist. '
|
||||
'Du erhältst einen Anmeldecode per E-Mail.',
|
||||
children: [
|
||||
TextFormField(
|
||||
key: const Key('guardian-email-field'),
|
||||
controller: _emailController,
|
||||
enabled: !_controller.loading,
|
||||
validator: _validateEmail,
|
||||
autocorrect: false,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
autofillHints: const [AutofillHints.email],
|
||||
textInputAction: TextInputAction.done,
|
||||
onFieldSubmitted: (_) => _requestCode(),
|
||||
decoration: loginInputDecoration(
|
||||
theme,
|
||||
'E-Mail-Adresse',
|
||||
Icons.alternate_email,
|
||||
),
|
||||
),
|
||||
LoginErrorBanner(
|
||||
message: _controller.errorMessage,
|
||||
details: _controller.errorDetails,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
LoginSubmitButton(
|
||||
key: const Key('guardian-request-button'),
|
||||
label: 'Code anfordern',
|
||||
loading: _controller.loading,
|
||||
onPressed: _requestCode,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCodeStep(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final pending = _controller.pending!;
|
||||
final remaining = pending.resendAvailableAt.difference(DateTime.now());
|
||||
return Form(
|
||||
key: _codeFormKey,
|
||||
child: LoginCardFrame(
|
||||
title: 'Code eingeben',
|
||||
hint:
|
||||
'Wir haben eine E-Mail an ${pending.email} gesendet. Gib den Code '
|
||||
'ein oder tippe auf den Link in der E-Mail.',
|
||||
children: [
|
||||
TextFormField(
|
||||
key: const Key('guardian-code-field'),
|
||||
controller: _codeController,
|
||||
enabled: !_controller.loading,
|
||||
validator: _validateCode,
|
||||
autofocus: true,
|
||||
keyboardType: TextInputType.number,
|
||||
autofillHints: const [AutofillHints.oneTimeCode],
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(pending.codeLength),
|
||||
],
|
||||
textInputAction: TextInputAction.done,
|
||||
onFieldSubmitted: (_) => _submitCode(),
|
||||
decoration: loginInputDecoration(
|
||||
theme,
|
||||
'Anmeldecode',
|
||||
Icons.pin_outlined,
|
||||
),
|
||||
),
|
||||
LoginErrorBanner(
|
||||
message: _controller.errorMessage,
|
||||
details: _controller.errorDetails,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
LoginSubmitButton(
|
||||
key: const Key('guardian-verify-button'),
|
||||
label: 'Anmelden',
|
||||
loading: _controller.loading,
|
||||
onPressed: _submitCode,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: _controller.loading ? null : _controller.changeEmail,
|
||||
child: const Text('E-Mail ändern'),
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
onPressed: _controller.loading || !_controller.canResend()
|
||||
? null
|
||||
: _controller.resend,
|
||||
child: Text(
|
||||
_controller.canResend()
|
||||
? 'Erneut senden'
|
||||
: 'Erneut senden (${remaining.inSeconds + 1} s)',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'login_form_parts.dart';
|
||||
|
||||
enum LoginAudience { school, guardian }
|
||||
|
||||
/// First login step: who is signing in. School accounts and guardians use
|
||||
/// entirely different forms, so the choice comes before any input.
|
||||
class LoginAudienceCard extends StatelessWidget {
|
||||
final ValueChanged<LoginAudience> onSelected;
|
||||
|
||||
const LoginAudienceCard({required this.onSelected, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => LoginCardFrame(
|
||||
title: 'Anmelden',
|
||||
hint: 'Bite wähle deine Anmeldemethode',
|
||||
children: [
|
||||
_AudienceButton(
|
||||
key: const Key('login-audience-school'),
|
||||
icon: Icons.school_outlined,
|
||||
label: 'Login für Schülerschaft & Lehrkräfte',
|
||||
onPressed: () => onSelected(LoginAudience.school),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_AudienceButton(
|
||||
key: const Key('login-audience-guardian'),
|
||||
icon: Icons.family_restroom_outlined,
|
||||
label: 'Login für Eltern',
|
||||
onPressed: () => onSelected(LoginAudience.guardian),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class _AudienceButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
const _AudienceButton({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onPressed,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SizedBox(
|
||||
height: 64,
|
||||
child: FilledButton.tonalIcon(
|
||||
onPressed: onPressed,
|
||||
icon: Icon(icon, size: 26),
|
||||
label: Text(label),
|
||||
style: FilledButton.styleFrom(
|
||||
alignment: Alignment.centerLeft,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -29,7 +29,7 @@ class LoginHeader extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Stundenplan, Talk & Dateien an einem Ort.',
|
||||
'Stundenplan, Talk, Dateien und mehr - alles an einem Ort für deinen Schulalltag am Marianum Fulda.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.85),
|
||||
@@ -41,24 +41,6 @@ class LoginHeader extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
class LoginDisclaimer extends StatelessWidget {
|
||||
const LoginDisclaimer({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text(
|
||||
'Alles für deinen Schulalltag am Marianum Fulda.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.75),
|
||||
fontSize: 11,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class LoginFooter extends StatelessWidget {
|
||||
const LoginFooter({super.key});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import '../../../routing/app_routes.dart';
|
||||
import '../login_controller.dart';
|
||||
import 'login_error_banner.dart';
|
||||
import 'login_form_parts.dart';
|
||||
|
||||
/// White Card hosting the login form (heading, two text fields, error
|
||||
/// banner, submit button). Submitting calls [controller.submit] and signals
|
||||
@@ -75,122 +76,62 @@ class _LoginCardState extends State<LoginCard> {
|
||||
}
|
||||
}
|
||||
|
||||
InputDecoration _decoration(ThemeData theme, String label, IconData icon) =>
|
||||
InputDecoration(
|
||||
labelText: label,
|
||||
prefixIcon: Icon(icon),
|
||||
filled: true,
|
||||
fillColor: theme.colorScheme.surfaceContainerHighest.withValues(
|
||||
alpha: 0.4,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: theme.colorScheme.primary, width: 1.5),
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final loading = widget.controller.loading;
|
||||
return Card(
|
||||
elevation: 8,
|
||||
shadowColor: Colors.black.withValues(alpha: 0.35),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
color: theme.colorScheme.surface,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 20),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Anmelden',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Melde dich mit deinen Marianum-Zugangsdaten an.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
TextFormField(
|
||||
key: const Key('login-username-field'),
|
||||
controller: _usernameController,
|
||||
enabled: !loading,
|
||||
validator: _required,
|
||||
autocorrect: false,
|
||||
textInputAction: TextInputAction.next,
|
||||
onFieldSubmitted: (_) => _passwordFocus.requestFocus(),
|
||||
decoration: _decoration(
|
||||
theme,
|
||||
'Nutzername',
|
||||
Icons.person_outline,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
key: const Key('login-password-field'),
|
||||
controller: _passwordController,
|
||||
focusNode: _passwordFocus,
|
||||
enabled: !loading,
|
||||
validator: _required,
|
||||
obscureText: true,
|
||||
obscuringCharacter: '•',
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
keyboardType: TextInputType.visiblePassword,
|
||||
textInputAction: TextInputAction.done,
|
||||
onFieldSubmitted: (_) => _submit(),
|
||||
decoration: _decoration(theme, 'Passwort', Icons.lock_outline),
|
||||
),
|
||||
LoginErrorBanner(
|
||||
message: widget.controller.errorMessage,
|
||||
details: widget.controller.errorDetails,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(
|
||||
height: 50,
|
||||
child: FilledButton(
|
||||
key: const Key('login-submit-button'),
|
||||
onPressed: loading ? null : _submit,
|
||||
style: FilledButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
child: loading
|
||||
? const SizedBox(
|
||||
height: 22,
|
||||
width: 22,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.5,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Text('Anmelden'),
|
||||
),
|
||||
),
|
||||
],
|
||||
return Form(
|
||||
key: _formKey,
|
||||
child: LoginCardFrame(
|
||||
title: 'Anmelden',
|
||||
hint: 'Melde dich mit deinen Marianum-Zugangsdaten an.',
|
||||
children: [
|
||||
TextFormField(
|
||||
key: const Key('login-username-field'),
|
||||
controller: _usernameController,
|
||||
enabled: !loading,
|
||||
validator: _required,
|
||||
autocorrect: false,
|
||||
textInputAction: TextInputAction.next,
|
||||
onFieldSubmitted: (_) => _passwordFocus.requestFocus(),
|
||||
decoration: loginInputDecoration(
|
||||
theme,
|
||||
'Nutzername',
|
||||
Icons.person_outline,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
key: const Key('login-password-field'),
|
||||
controller: _passwordController,
|
||||
focusNode: _passwordFocus,
|
||||
enabled: !loading,
|
||||
validator: _required,
|
||||
obscureText: true,
|
||||
obscuringCharacter: '•',
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
keyboardType: TextInputType.visiblePassword,
|
||||
textInputAction: TextInputAction.done,
|
||||
onFieldSubmitted: (_) => _submit(),
|
||||
decoration: loginInputDecoration(
|
||||
theme,
|
||||
'Passwort',
|
||||
Icons.lock_outline,
|
||||
),
|
||||
),
|
||||
LoginErrorBanner(
|
||||
message: widget.controller.errorMessage,
|
||||
details: widget.controller.errorDetails,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
LoginSubmitButton(
|
||||
key: const Key('login-submit-button'),
|
||||
label: 'Anmelden',
|
||||
loading: loading,
|
||||
onPressed: _submit,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Filled, borderless text field look shared by both login cards.
|
||||
InputDecoration loginInputDecoration(
|
||||
ThemeData theme,
|
||||
String label,
|
||||
IconData icon,
|
||||
) => InputDecoration(
|
||||
labelText: label,
|
||||
prefixIcon: Icon(icon),
|
||||
filled: true,
|
||||
fillColor: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.4),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: theme.colorScheme.primary, width: 1.5),
|
||||
),
|
||||
);
|
||||
|
||||
/// Card frame with heading and hint line shared by both login cards.
|
||||
class LoginCardFrame extends StatelessWidget {
|
||||
final String title;
|
||||
final String hint;
|
||||
final List<Widget> children;
|
||||
|
||||
const LoginCardFrame({
|
||||
required this.title,
|
||||
required this.hint,
|
||||
required this.children,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Card(
|
||||
elevation: 8,
|
||||
shadowColor: Colors.black.withValues(alpha: 0.35),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
color: theme.colorScheme.surface,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
hint,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
...children,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Full-width primary button that swaps its label for a spinner while busy.
|
||||
class LoginSubmitButton extends StatelessWidget {
|
||||
final String label;
|
||||
final bool loading;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
const LoginSubmitButton({
|
||||
required this.label,
|
||||
required this.loading,
|
||||
required this.onPressed,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SizedBox(
|
||||
height: 50,
|
||||
child: FilledButton(
|
||||
onPressed: loading ? null : onPressed,
|
||||
style: FilledButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
textStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
|
||||
),
|
||||
child: loading
|
||||
? const SizedBox(
|
||||
height: 22,
|
||||
width: 22,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.5,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: Text(label),
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user