added guardian login with views for their assigned childs

This commit is contained in:
2026-09-20 11:11:58 +02:00
parent e4e2b1a4fb
commit 67c935c05b
117 changed files with 4784 additions and 1514 deletions
@@ -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)',
),
),
],
),
],
),
);
}
}