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 '../../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'; 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'; 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'; class Login extends StatefulWidget { const Login({super.key}); @override State createState() => _LoginState(); } class _LoginState extends State with SingleTickerProviderStateMixin { static const _marianumRed = LightAppTheme.marianumRed; final LoginController _controller = LoginController(); final GuardianLoginController _guardianController = GuardianLoginController(); late final Future _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(); 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. Future _consumeGuardianLink() async { final uri = GuardianLinkListener.pending.value; if (uri == null) return; GuardianLinkListener.clear(); final link = GuardianLoginLink.parse( uri, apiBase: Uri.parse(mc.MarianumConnectEndpoint.current()), ); 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(); } @override void didChangeDependencies() { super.didChangeDependencies(); precacheImage(const AssetImage('assets/logo/icon.png'), context); unawaited(PostLoginSplash.precache()); } @override void dispose() { GuardianLinkListener.pending.removeListener(_consumeGuardianLink); _fade.dispose(); _controller.dispose(); _guardianController.dispose(); super.dispose(); } void _onLoginSuccess() { Haptics.heavyAccent(); // Re-register the periodic refresh (cancelAll runs on logout) and kick // off an immediate one-off so the widget populates within seconds // instead of waiting up to 30 minutes for the next periodic slot. unawaited(WidgetBackgroundTask.initialize()); unawaited(WidgetBackgroundTask.requestImmediateRefresh()); // Fade the login content out before handing over to the post-login splash. // Both share the red backdrop, so this reads as one continuous transition // instead of an abrupt swap. _fade.reverse().whenComplete(() { if (!mounted) return; context.read().setStatus(AccountStatus.loggedIn); }); } 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, body: FadeTransition( opacity: _fade, child: SafeArea( child: LayoutBuilder( builder: (context, constraints) => SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: 24), child: Center( child: ConstrainedBox( constraints: BoxConstraints( minHeight: constraints.maxHeight, maxWidth: 420, ), // spaceBetween statt Spacer-in-IntrinsicHeight: Letzteres würde // die Column bei Inhaltsänderungen im unteren Block auf die // intrinsic-Höhe pinnen und ein paar Pixel Overflow erzeugen. child: Column( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Column( children: [ const LoginHeader(), const SizedBox(height: 28), _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( mainAxisSize: MainAxisSize.min, children: [_EndpointLink(), LoginFooter()], ), ], ), ), ), ), ), ), ), ); } /// Subtle text link above the footer that surfaces the currently selected /// Marianum-Connect endpoint and opens the picker on tap. Always visible so /// devs can switch the endpoint before the first login without hunting for a /// long-press easter egg, but understated enough not to draw regular users /// into the dev menu. class _EndpointLink extends StatelessWidget { const _EndpointLink(); @override Widget build(BuildContext context) => BlocBuilder( builder: (context, settings) { final dev = settings.devToolsSettings; final label = _label(dev); return Padding( padding: const EdgeInsets.only(bottom: 4), child: TextButton( style: TextButton.styleFrom( foregroundColor: Colors.white.withValues(alpha: 0.85), padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 4, ), minimumSize: const Size(0, 28), tapTargetSize: MaterialTapTargetSize.shrinkWrap, textStyle: const TextStyle( fontSize: 12, fontWeight: FontWeight.w500, decoration: TextDecoration.underline, ), ), onPressed: () => MarianumConnectEndpointPicker.show( context, context.read(), ), child: Text('Server: $label'), ), ); }, ); static String _label(DevToolsSettings dev) { switch (dev.marianumConnectEndpoint) { case MarianumConnectEndpoint.live: return 'Normal'; case MarianumConnectEndpoint.beta: return 'Beta'; case MarianumConnectEndpoint.custom: final url = DevToolsSettings.sanitizeCustomUrl( dev.marianumConnectCustomUrl, ); return url ?? 'Eigener Server (ungültig)'; } } }