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
+2 -2
View File
@@ -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);
}
}
+82 -6
View File
@@ -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(
+23 -18
View File
@@ -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),
),
),
);
}
+1 -19
View File
@@ -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});
+52 -111
View File
@@ -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),
),
);
}