- New VerantwortlicheInvite model: LT-issued invites so a person can register as Gemeinde Verantwortliche(r) for a specific Gemeinde, skipping the self-registration approval step. - Wahl/Workshop/Teilnehmer gain phase support (phasenAnzahl, beschreibung), mirroring the WP plugin's multi-phase elections. Teilnehmer unique constraint now scoped per phase. - Auth: team login + guest auth adjustments, spec coverage. - sync.service.ts: register VerantwortlicheInvite as a synced model. - wahl.service.ts: submitTeilnehmer updated for the new phase-scoped unique key. - client: login/home screen rework, new theme.dart, FCM web tweaks. - .gitignore: ignore .DS_Store. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
370 lines
12 KiB
Dart
370 lines
12 KiB
Dart
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<LoginScreen> createState() => _LoginScreenState();
|
|
}
|
|
|
|
class _LoginScreenState extends State<LoginScreen> {
|
|
@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<Widget> fields;
|
|
final Future<void> Function() onSubmit;
|
|
final String submitLabel;
|
|
|
|
@override
|
|
State<_FormShell> createState() => _FormShellState();
|
|
}
|
|
|
|
class _FormShellState extends State<_FormShell> {
|
|
bool _busy = false;
|
|
String? _error;
|
|
|
|
Future<void> _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<String>? 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(),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|