import 'dart:developer'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../api/errors/auth_exception.dart'; import '../../api/errors/error_mapper.dart'; import '../../api/marianumcloud/talk/room/get_room.dart'; import '../../api/marianumcloud/talk/room/get_room_params.dart'; import '../../model/account_data.dart'; import '../../state/app/modules/account/bloc/account_bloc.dart'; import '../../state/app/modules/account/bloc/account_state.dart'; import '../../theming/light_app_theme.dart'; class Login extends StatefulWidget { const Login({super.key}); @override State createState() => _LoginState(); } class _LoginState extends State { static const _marianumRed = LightAppTheme.marianumRed; final _formKey = GlobalKey(); final _usernameController = TextEditingController(); final _passwordController = TextEditingController(); final _passwordFocus = FocusNode(); bool _loading = false; String? _errorMessage; String? _errorDetails; @override void didChangeDependencies() { super.didChangeDependencies(); precacheImage(const AssetImage('assets/logo/icon.png'), context); } @override void dispose() { _usernameController.dispose(); _passwordController.dispose(); _passwordFocus.dispose(); super.dispose(); } String? _required(String? value) => (value ?? '').trim().isEmpty ? 'Eingabe erforderlich' : null; Future _submit() async { if (_loading) return; if (!(_formKey.currentState?.validate() ?? false)) return; setState(() { _loading = true; _errorMessage = null; _errorDetails = null; }); final username = _usernameController.text.trim().toLowerCase(); final password = _passwordController.text; try { await AccountData().removeData(); await AccountData().setData(username, password); await GetRoom(GetRoomParams(includeStatus: false)).run(); if (!mounted) return; context.read().setStatus(AccountStatus.loggedIn); } catch (e) { log(e.toString()); await AccountData().removeData(); if (!mounted) return; // 401 from the probe means the credentials were wrong; everything else // (no network, server down, TLS errors, …) gets the generic mapped // message so the user knows it isn't their typo. final isWrongCredentials = e is AuthException && e.statusCode == 401; setState(() { _errorMessage = isWrongCredentials ? 'Benutzername oder Passwort falsch.' : errorToUserMessage(e); _errorDetails = errorToTechnicalDetails(e); }); } finally { if (mounted) setState(() => _loading = false); } } void _showErrorDetails() { final details = _errorDetails; if (details == null) return; showDialog( context: context, builder: (dialogContext) { final theme = Theme.of(dialogContext); return AlertDialog( icon: Icon(Icons.error_outline, color: theme.colorScheme.error), title: const Text('Fehlerdetails'), content: SingleChildScrollView( child: SelectableText( details, style: theme.textTheme.bodySmall, ), ), actions: [ TextButton.icon( onPressed: () async { await Clipboard.setData(ClipboardData(text: details)); if (!dialogContext.mounted) return; ScaffoldMessenger.of(dialogContext).showSnackBar( const SnackBar( content: Text('In Zwischenablage kopiert'), duration: Duration(seconds: 2), ), ); }, icon: const Icon(Icons.copy_outlined, size: 18), label: const Text('Kopieren'), ), TextButton( onPressed: () => Navigator.of(dialogContext).pop(), child: const Text('Schließen'), ), ], ); }, ); } @override Widget build(BuildContext context) { final theme = Theme.of(context); return Scaffold( backgroundColor: _marianumRed, body: SafeArea( child: LayoutBuilder( builder: (context, constraints) => SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: 24), child: ConstrainedBox( constraints: BoxConstraints(minHeight: constraints.maxHeight), child: IntrinsicHeight( child: Column( children: [ const SizedBox(height: 40), Image.asset( 'assets/logo/icon.png', height: 110, fit: BoxFit.contain, gaplessPlayback: true, ), const SizedBox(height: 20), const Text( 'Marianum Fulda', textAlign: TextAlign.center, style: TextStyle( color: Colors.white, fontSize: 26, fontWeight: FontWeight.w600, letterSpacing: 0.3, ), ), const SizedBox(height: 6), Text( 'Stundenplan, Talk & Dateien an einem Ort.', textAlign: TextAlign.center, style: TextStyle( color: Colors.white.withValues(alpha: 0.85), fontSize: 14, height: 1.3, ), ), const SizedBox(height: 28), ConstrainedBox( constraints: const BoxConstraints(maxWidth: 420), child: 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( controller: _usernameController, enabled: !_loading, validator: _required, autocorrect: false, textInputAction: TextInputAction.next, onFieldSubmitted: (_) => _passwordFocus.requestFocus(), decoration: InputDecoration( labelText: 'Nutzername', prefixIcon: const Icon(Icons.person_outline), 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), ), ), ), const SizedBox(height: 12), TextFormField( controller: _passwordController, focusNode: _passwordFocus, enabled: !_loading, validator: _required, obscureText: true, obscuringCharacter: '•', autocorrect: false, enableSuggestions: false, keyboardType: TextInputType.visiblePassword, textInputAction: TextInputAction.done, onFieldSubmitted: (_) => _submit(), decoration: InputDecoration( labelText: 'Passwort', prefixIcon: const Icon(Icons.lock_outline), 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), ), ), ), AnimatedSize( duration: const Duration(milliseconds: 180), curve: Curves.easeOut, child: _errorMessage == null ? const SizedBox(height: 0, width: double.infinity) : Padding( padding: const EdgeInsets.only(top: 14), child: Material( color: theme.colorScheme.errorContainer.withValues(alpha: 0.6), borderRadius: BorderRadius.circular(12), child: InkWell( onTap: _errorDetails != null ? _showErrorDetails : null, borderRadius: BorderRadius.circular(12), child: Padding( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 10), child: Row( children: [ Icon(Icons.error_outline, size: 20, color: theme.colorScheme.onErrorContainer), const SizedBox(width: 10), Expanded( child: Text( _errorMessage!, style: TextStyle( color: theme.colorScheme.onErrorContainer, fontSize: 13, height: 1.3, ), ), ), if (_errorDetails != null) ...[ const SizedBox(width: 8), Icon(Icons.chevron_right, size: 20, color: theme.colorScheme.onErrorContainer .withValues(alpha: 0.7)), ], ], ), ), ), ), ), ), const SizedBox(height: 20), SizedBox( height: 50, child: FilledButton( 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'), ), ), ], ), ), ), ), ), const SizedBox(height: 18), Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: Text( 'Inoffizieller Nextcloud & Webuntis Client. Wird nicht vom Marianum betrieben. Keine Gewähr für Vollständigkeit, Richtigkeit und Aktualität.', textAlign: TextAlign.center, style: TextStyle( color: Colors.white.withValues(alpha: 0.75), fontSize: 11, height: 1.4, ), ), ), const Spacer(), Padding( padding: const EdgeInsets.only(top: 16, bottom: 8), child: Text( 'Marianum Fulda. Die persönliche Schule.', textAlign: TextAlign.center, style: TextStyle( color: Colors.white.withValues(alpha: 0.7), fontSize: 12, fontStyle: FontStyle.italic, ), ), ), ], ), ), ), ), ), ), ); } }