import 'package:flutter/material.dart'; import '../api.dart'; import '../main.dart'; import '../theme.dart'; /// A single login screen — one card, no tabs, no role switcher. The KC-Code /// field drives Konfi vs. Leitungsteam: a plain code reveals the Konfi name /// fields; appending "LT" to the code (e.g. "ABC123LT") reveals the /// Leitungsteam Authentik button instead. Gemeinde Teamer:in has its own /// section below, logging in with the Gemeinde name instead of an email. class LoginScreen extends StatefulWidget { const LoginScreen({super.key}); @override State createState() => _LoginScreenState(); } class _LoginScreenState extends State { @override Widget build(BuildContext context) { return Scaffold( body: SafeArea( child: Center( child: SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 420), child: Column( mainAxisSize: MainAxisSize.min, children: [ const _Brand(), const SizedBox(height: 28), Card( child: Padding( padding: const EdgeInsets.fromLTRB(24, 28, 24, 24), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: const [ _KonfiOrLeitungsteamSection(), Divider(height: 40), _TeamerSection(), ], ), ), ), ], ), ), ), ), ), ); } } class _Brand extends StatelessWidget { const _Brand(); @override Widget build(BuildContext context) { return Column( children: [ Container( width: 64, height: 64, decoration: BoxDecoration( gradient: const LinearGradient( colors: [KcColors.blue, KcColors.teal], begin: Alignment.topLeft, end: Alignment.bottomRight, ), borderRadius: BorderRadius.circular(18), ), child: const Icon(Icons.castle_outlined, color: Colors.white, size: 32), ), const SizedBox(height: 16), const Text( 'KC-App', style: TextStyle(fontSize: 26, fontWeight: FontWeight.w800, color: KcColors.navy), ), const SizedBox(height: 4), Text('Konfi-Castle Events', style: TextStyle(fontSize: 14, color: KcColors.slate)), ], ); } } /// Shared submit-button + error handling. class _FormShell extends StatefulWidget { const _FormShell({required this.fields, required this.onSubmit, this.submitLabel = 'Anmelden'}); final List fields; final Future Function() onSubmit; final String submitLabel; @override State<_FormShell> createState() => _FormShellState(); } class _FormShellState extends State<_FormShell> { bool _busy = false; String? _error; Future _run() async { setState(() { _busy = true; _error = null; }); try { await widget.onSubmit(); } on ApiException catch (e) { setState(() => _error = e.message); } catch (e) { setState(() => _error = e.toString()); } finally { if (mounted) setState(() => _busy = false); } } @override Widget build(BuildContext context) { return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ...widget.fields, if (_error != null) ...[ const SizedBox(height: 8), Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), ], const SizedBox(height: 16), FilledButton( onPressed: _busy ? null : _run, child: _busy ? const SizedBox( height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) : Text(widget.submitLabel), ), ], ); } } TextField _field(TextEditingController c, String label, {bool obscure = false, IconData? icon, ValueChanged? onChanged}) => TextField( controller: c, obscureText: obscure, onChanged: onChanged, decoration: InputDecoration( labelText: label, prefixIcon: icon != null ? Icon(icon, size: 20) : null, ), ); const _fieldGap = SizedBox(height: 12); /// One code field drives two different logins: a plain KC-Code reveals the /// Konfi name fields; a code ending in "LT" (e.g. "ABC123LT") reveals the /// Leitungsteam Authentik button instead — no separate role picker needed. class _KonfiOrLeitungsteamSection extends StatefulWidget { const _KonfiOrLeitungsteamSection(); @override State<_KonfiOrLeitungsteamSection> createState() => _KonfiOrLeitungsteamSectionState(); } class _KonfiOrLeitungsteamSectionState extends State<_KonfiOrLeitungsteamSection> { final _code = TextEditingController(); final _first = TextEditingController(); final _last = TextEditingController(); bool get _isLeitungsteamCode { final c = _code.text.trim(); return c.length > 2 && c.toUpperCase().endsWith('LT'); } /// The KC-Code with a trailing "LT" trigger stripped back off, so /// "ABC123LT" still resolves to the real invite code "ABC123". String get _plainCode { final c = _code.text.trim(); return _isLeitungsteamCode ? c.substring(0, c.length - 2) : c; } @override Widget build(BuildContext context) { final state = AppScope.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _field( _code, 'KC-Code', icon: Icons.confirmation_number_outlined, onChanged: (_) => setState(() {}), ), const SizedBox(height: 4), Text( 'Konfi: gib deinen KC-Code ein. Leitungsteam: hänge "LT" an den ' 'Code an (z. B. "ABC123LT").', style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 16), AnimatedSwitcher( duration: const Duration(milliseconds: 180), child: _code.text.trim().isEmpty ? const SizedBox.shrink(key: ValueKey('empty')) : _isLeitungsteamCode ? _LeitungsteamLogin(key: const ValueKey('lt'), state: state) : Column( key: const ValueKey('konfi'), crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _field(_first, 'Vorname', icon: Icons.badge_outlined), _fieldGap, _field(_last, 'Nachname'), const SizedBox(height: 4), _FormShell( submitLabel: 'Los geht\'s', fields: const [], onSubmit: () => state.guestLogin( _plainCode, _first.text.trim(), _last.text.trim(), ), ), const SizedBox(height: 4), Text( 'Meldest du dich erneut mit demselben Code und Namen ' 'an, kommst du in deinen bestehenden Account zurück.', style: Theme.of(context).textTheme.bodySmall, textAlign: TextAlign.center, ), ], ), ), ], ); } } class _LeitungsteamLogin extends StatelessWidget { const _LeitungsteamLogin({super.key, required this.state}); final AppState state; @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (state.authError != null) ...[ Text(state.authError!, style: TextStyle(color: Theme.of(context).colorScheme.error)), const SizedBox(height: 12), ], FilledButton.icon( onPressed: () => state.beginOidcLogin(), icon: const Icon(Icons.login, size: 20), label: const Text('Mit Konfi-Castle-ID anmelden'), ), const SizedBox(height: 8), Text( 'Öffnet die Konfi-Castle-ID (Authentik). Leitungsteam-Rechte und ' 'Gemeinde-Zuordnungen kommen automatisch aus deinem Account.', style: Theme.of(context).textTheme.bodySmall, textAlign: TextAlign.center, ), ], ); } } /// Gemeinde Teamer:in login — Gemeinde name instead of email, since that's /// what a Teamer actually thinks of as "their" login. Invite redemption for /// a first-time account is folded in underneath. class _TeamerSection extends StatefulWidget { const _TeamerSection(); @override State<_TeamerSection> createState() => _TeamerSectionState(); } class _TeamerSectionState extends State<_TeamerSection> { final _gemeinde = TextEditingController(); final _password = TextEditingController(); bool _showInvite = false; @override Widget build(BuildContext context) { final state = AppScope.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text('Gemeinde Teamer:in', style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 16), _FormShell( fields: [ _field(_gemeinde, 'Gemeinde', icon: Icons.groups_outlined), _fieldGap, _field(_password, 'Passwort', obscure: true, icon: Icons.lock_outline), ], onSubmit: () => state.teamLogin( gemeindeName: _gemeinde.text.trim(), password: _password.text, ), ), const SizedBox(height: 4), Center( child: TextButton( onPressed: () => setState(() => _showInvite = !_showInvite), child: Text(_showInvite ? 'Einladung ausblenden' : 'Noch kein Konto? Einladung einlösen'), ), ), if (_showInvite) ...[ const Divider(height: 28), const _InviteForm(), ], ], ); } } class _InviteForm extends StatefulWidget { const _InviteForm(); @override State<_InviteForm> createState() => _InviteFormState(); } class _InviteFormState extends State<_InviteForm> { final _token = TextEditingController(); final _first = TextEditingController(); final _last = TextEditingController(); final _email = TextEditingController(); final _password = TextEditingController(); @override Widget build(BuildContext context) { final state = AppScope.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text('Teamer:in-Einladung einlösen', style: Theme.of(context).textTheme.titleMedium, textAlign: TextAlign.center), const SizedBox(height: 16), _FormShell( submitLabel: 'Konto anlegen', fields: [ _field(_token, 'Einladungscode / Token'), _fieldGap, _field(_first, 'Vorname'), _fieldGap, _field(_last, 'Nachname'), _fieldGap, _field(_email, 'E-Mail (bei Gruppen-Link nötig)'), _fieldGap, _field(_password, 'Passwort wählen (min. 8 Zeichen)', obscure: true), ], onSubmit: () => state.redeemInvite( token: _token.text.trim(), first: _first.text.trim(), last: _last.text.trim(), password: _password.text, email: _email.text.trim(), ), ), ], ); } }