import 'dart:async'; import 'dart:developer'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../background/widget_background_task.dart'; import '../../session/session_lifecycle.dart'; import '../../session/session_manager.dart'; import '../../state/app/modules/account/bloc/account_bloc.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 'login_controller.dart'; import 'post_login_splash.dart'; import 'widgets/login_branding.dart'; import 'widgets/login_card.dart'; class Login extends StatefulWidget { /// Signs in another account while one is active; offers to go back. final bool addingAccount; const Login({super.key, this.addingAccount = false}); @override State createState() => _LoginState(); } class _LoginState extends State with SingleTickerProviderStateMixin { static const _marianumRed = LightAppTheme.marianumRed; final LoginController _controller = LoginController(); late final AnimationController _fade = AnimationController( vsync: this, duration: const Duration(milliseconds: 450), value: 1, ); @override void initState() { super.initState(); 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'); } @override void didChangeDependencies() { super.didChangeDependencies(); precacheImage(const AssetImage('assets/logo/icon.png'), context); unawaited(PostLoginSplash.precache()); } @override void dispose() { _fade.dispose(); _controller.dispose(); super.dispose(); } Future _onLoginSuccess() async { Haptics.heavyAccent(); final accountBloc = context.read(); // 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. final finished = SessionLifecycle.finishLogin(); await _fade.reverse().orCancel.onError((_, _) {}); String? accountId; try { accountId = await finished; } on Object catch (e) { log('Login: finishing failed: $e'); accountId = SessionManager().activeAccount?.id; } // 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()); accountBloc.activated(accountId, freshLogin: true); } Future _cancelAddAccount() async { final accountBloc = context.read(); await SessionLifecycle.cancelAddAccount(); accountBloc.activated(SessionManager().activeAccount?.id); } @override Widget build(BuildContext context) => PopScope( canPop: !widget.addingAccount, onPopInvokedWithResult: (didPop, _) { if (!didPop && !_busy) unawaited(_cancelAddAccount()); }, child: Scaffold( backgroundColor: _marianumRed, appBar: widget.addingAccount ? AppBar( backgroundColor: Colors.transparent, foregroundColor: Colors.white, elevation: 0, leading: ListenableBuilder( listenable: _controller, builder: (context, _) => IconButton( icon: const Icon(Icons.close), tooltip: 'Abbrechen', onPressed: _busy ? null : _cancelAddAccount, ), ), title: const Text('Konto hinzufügen'), ) : null, 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: [ LoginHeader(addingAccount: widget.addingAccount), const SizedBox(height: 28), LoginCard( controller: _controller, onSuccess: _onLoginSuccess, addingAccount: widget.addingAccount, ), const SizedBox(height: 12), ], ), Column( mainAxisSize: MainAxisSize.min, children: [ // The new account must live on the server the app // already talks to. if (!widget.addingAccount) const _EndpointLink(), const LoginFooter(), ], ), ], ), ), ), ), ), ), ), ), ); bool get _busy => _controller.loading; } /// 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)'; } } }