feat(backend): Wahl-Phasen, Verantwortliche-Invites, Auth fixes
- 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>
This commit is contained in:
+10
-4
@@ -450,8 +450,14 @@ class Api {
|
||||
return j['accessToken'] as String;
|
||||
}
|
||||
|
||||
Future<String> teamLogin(String email, String password) async {
|
||||
final j = await _post('/auth/team-login', {'email': email, 'password': password});
|
||||
/// Logs a Gemeinde Teamer in by Gemeinde name (the normal path) or by
|
||||
/// email (legacy/personal accounts) — pass exactly one of the two.
|
||||
Future<String> teamLogin({String? gemeindeName, String? email, required String password}) async {
|
||||
final j = await _post('/auth/team-login', {
|
||||
if (gemeindeName != null && gemeindeName.isNotEmpty) 'gemeindeName': gemeindeName,
|
||||
if (email != null && email.isNotEmpty) 'email': email,
|
||||
'password': password,
|
||||
});
|
||||
return j['accessToken'] as String;
|
||||
}
|
||||
|
||||
@@ -772,8 +778,8 @@ class AppState extends ChangeNotifier {
|
||||
Future<void> guestLogin(String code, String first, String last) =>
|
||||
_api.guestLogin(code, first, last).then(_establish);
|
||||
|
||||
Future<void> teamLogin(String email, String password) =>
|
||||
_api.teamLogin(email, password).then(_establish);
|
||||
Future<void> teamLogin({String? gemeindeName, String? email, required String password}) =>
|
||||
_api.teamLogin(gemeindeName: gemeindeName, email: email, password: password).then(_establish);
|
||||
|
||||
Future<void> redeemInvite({
|
||||
required String token,
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:http/http.dart' as http;
|
||||
import 'api.dart';
|
||||
import 'screens/home_screen.dart';
|
||||
import 'screens/login_screen.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
void main() {
|
||||
final state = AppState(Api(http.Client()))..bootstrap();
|
||||
@@ -34,10 +35,7 @@ class KcApp extends StatelessWidget {
|
||||
child: MaterialApp(
|
||||
title: 'KC-App',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(
|
||||
colorSchemeSeed: const Color(0xFF3B5BA5),
|
||||
useMaterial3: true,
|
||||
),
|
||||
theme: buildKcTheme(),
|
||||
home: const _AuthGate(),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../api.dart';
|
||||
import '../main.dart';
|
||||
import '../theme.dart';
|
||||
import 'admin_screen.dart';
|
||||
import 'chat_screen.dart';
|
||||
import 'files_screen.dart';
|
||||
@@ -109,8 +110,7 @@ class _IdentityCard extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final lines = <String>[
|
||||
'Rolle: ${id.roleLabel}',
|
||||
if (id.email != null) 'E-Mail: ${id.email}',
|
||||
if (id.email != null) id.email!,
|
||||
if (id.isLeitungsteam)
|
||||
'Leitungsteam-Rechte gelten KC-übergreifend.'
|
||||
else if (id.memberships.length > 1)
|
||||
@@ -118,13 +118,35 @@ class _IdentityCard extends StatelessWidget {
|
||||
];
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Row(
|
||||
children: [
|
||||
Text('Angemeldet', style: Theme.of(context).textTheme.labelMedium),
|
||||
const SizedBox(height: 4),
|
||||
for (final l in lines) Text(l),
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [KcColors.blue, KcColors.teal],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.person, color: Colors.white),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(id.roleLabel, style: Theme.of(context).textTheme.titleMedium),
|
||||
for (final l in lines) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(l, style: Theme.of(context).textTheme.bodySmall),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -147,12 +169,38 @@ class _NavTile extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: ListTile(
|
||||
leading: Icon(icon),
|
||||
title: Text(title),
|
||||
subtitle: Text(subtitle),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: KcColors.blue.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(icon, color: KcColors.blue),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 2),
|
||||
Text(subtitle, style: Theme.of(context).textTheme.bodySmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(Icons.chevron_right, color: KcColors.slate.withValues(alpha: 0.6)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,38 +2,49 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../api.dart';
|
||||
import '../main.dart';
|
||||
import '../theme.dart';
|
||||
|
||||
class LoginScreen extends StatelessWidget {
|
||||
/// 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 DefaultTabController(
|
||||
length: 3,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('KC-App'),
|
||||
bottom: const TabBar(
|
||||
tabs: [
|
||||
Tab(text: 'Konfi / Gast'),
|
||||
Tab(text: 'Team-Login'),
|
||||
Tab(text: 'Einladung'),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: TabBarView(
|
||||
children: const [
|
||||
_GuestForm(),
|
||||
_TeamForm(),
|
||||
_InviteForm(),
|
||||
],
|
||||
),
|
||||
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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -43,12 +54,44 @@ class LoginScreen extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared submit-button + error handling for the three little forms.
|
||||
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.title, required this.fields, required this.onSubmit});
|
||||
final String title;
|
||||
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();
|
||||
@@ -76,126 +119,203 @@ class _FormShellState extends State<_FormShell> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView(
|
||||
shrinkWrap: true,
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (widget.title.isNotEmpty) ...[
|
||||
Text(widget.title, style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
...widget.fields,
|
||||
const SizedBox(height: 20),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: _busy ? null : _run,
|
||||
child: _busy
|
||||
? const SizedBox(
|
||||
height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Text('Weiter'),
|
||||
height: 18, width: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
||||
: Text(widget.submitLabel),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TextField _field(TextEditingController c, String label, {bool obscure = false}) => TextField(
|
||||
TextField _field(TextEditingController c, String label,
|
||||
{bool obscure = false, IconData? icon, ValueChanged<String>? onChanged}) =>
|
||||
TextField(
|
||||
controller: c,
|
||||
obscureText: obscure,
|
||||
decoration: InputDecoration(labelText: label, border: const OutlineInputBorder()),
|
||||
onChanged: onChanged,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
prefixIcon: icon != null ? Icon(icon, size: 20) : null,
|
||||
),
|
||||
);
|
||||
|
||||
class _GuestForm extends StatefulWidget {
|
||||
const _GuestForm();
|
||||
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<_GuestForm> createState() => _GuestFormState();
|
||||
State<_KonfiOrLeitungsteamSection> createState() => _KonfiOrLeitungsteamSectionState();
|
||||
}
|
||||
|
||||
class _GuestFormState extends State<_GuestForm> {
|
||||
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 _FormShell(
|
||||
title: 'Mit Einladungscode beitreten',
|
||||
fields: [
|
||||
_field(_code, 'Einladungscode'),
|
||||
const SizedBox(height: 12),
|
||||
_field(_first, 'Vorname'),
|
||||
const SizedBox(height: 12),
|
||||
_field(_last, 'Nachname'),
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
onSubmit: () => state.guestLogin(_code.text.trim(), _first.text.trim(), _last.text.trim()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TeamForm extends StatefulWidget {
|
||||
const _TeamForm();
|
||||
@override
|
||||
State<_TeamForm> createState() => _TeamFormState();
|
||||
}
|
||||
|
||||
class _TeamFormState extends State<_TeamForm> {
|
||||
final _email = TextEditingController();
|
||||
final _password = TextEditingController();
|
||||
class _LeitungsteamLogin extends StatelessWidget {
|
||||
const _LeitungsteamLogin({super.key, required this.state});
|
||||
final AppState state;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = AppScope.of(context);
|
||||
return ListView(
|
||||
shrinkWrap: true,
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('Leitungsteam / Verantwortliche',
|
||||
style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 12),
|
||||
if (state.authError != null) ...[
|
||||
Text(state.authError!,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
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),
|
||||
icon: const Icon(Icons.login, size: 20),
|
||||
label: const Text('Mit Konfi-Castle-ID anmelden'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Öffnet die Konfi-Castle-ID (Authentik). Leitungsteam-Rechte kommen '
|
||||
'aus deiner Authentik-Gruppe.',
|
||||
style: TextStyle(fontSize: 12),
|
||||
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,
|
||||
),
|
||||
const Divider(height: 40),
|
||||
Text('Lokaler Teamer:in-Login',
|
||||
style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 12),
|
||||
_TeamPasswordForm(email: _email, password: _password),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TeamPasswordForm extends StatelessWidget {
|
||||
const _TeamPasswordForm({required this.email, required this.password});
|
||||
final TextEditingController email;
|
||||
final TextEditingController password;
|
||||
/// 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 _FormShell(
|
||||
title: '',
|
||||
fields: [
|
||||
_field(email, 'E-Mail'),
|
||||
const SizedBox(height: 12),
|
||||
_field(password, 'Passwort', obscure: true),
|
||||
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(),
|
||||
],
|
||||
],
|
||||
onSubmit: () => state.teamLogin(email.text.trim(), password.text),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -216,26 +336,34 @@ class _InviteFormState extends State<_InviteForm> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = AppScope.of(context);
|
||||
return _FormShell(
|
||||
title: 'Teamer:in-Einladung einlösen',
|
||||
fields: [
|
||||
_field(_token, 'Einladungscode / Token'),
|
||||
const SizedBox(height: 12),
|
||||
_field(_first, 'Vorname'),
|
||||
const SizedBox(height: 12),
|
||||
_field(_last, 'Nachname'),
|
||||
const SizedBox(height: 12),
|
||||
_field(_email, 'E-Mail (bei Gruppen-Link nötig)'),
|
||||
const SizedBox(height: 12),
|
||||
_field(_password, 'Passwort wählen (min. 8 Zeichen)', obscure: true),
|
||||
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(),
|
||||
),
|
||||
),
|
||||
],
|
||||
onSubmit: () => state.redeemInvite(
|
||||
token: _token.text.trim(),
|
||||
first: _first.text.trim(),
|
||||
last: _last.text.trim(),
|
||||
password: _password.text,
|
||||
email: _email.text.trim(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Brand palette lifted from konfi-castle.com (Kubio theme CSS custom
|
||||
/// properties: --kubio-color-1..6) so the app's look leans on the same
|
||||
/// identity as the marketing site instead of Flutter's default Material
|
||||
/// purple.
|
||||
abstract final class KcColors {
|
||||
static const blue = Color(0xFF2F7CFF); // --kubio-color-1
|
||||
static const orange = Color(0xFFF17C20); // --kubio-color-2
|
||||
static const teal = Color(0xFF4EBA9A); // --kubio-color-3
|
||||
static const slate = Color(0xFF69768B); // --kubio-color-4
|
||||
static const navy = Color(0xFF2B2D42); // --kubio-color-6 (headings/text)
|
||||
static const surface = Color(0xFFF7F9FC);
|
||||
}
|
||||
|
||||
ThemeData buildKcTheme() {
|
||||
final colorScheme = ColorScheme.fromSeed(
|
||||
seedColor: KcColors.blue,
|
||||
brightness: Brightness.light,
|
||||
).copyWith(
|
||||
primary: KcColors.blue,
|
||||
secondary: KcColors.orange,
|
||||
tertiary: KcColors.teal,
|
||||
surface: KcColors.surface,
|
||||
onSurface: KcColors.navy,
|
||||
);
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: colorScheme,
|
||||
scaffoldBackgroundColor: KcColors.surface,
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: KcColors.surface,
|
||||
foregroundColor: KcColors.navy,
|
||||
elevation: 0,
|
||||
centerTitle: false,
|
||||
titleTextStyle: const TextStyle(
|
||||
color: KcColors.navy,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
elevation: 0,
|
||||
color: Colors.white,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
side: BorderSide(color: KcColors.navy.withValues(alpha: 0.06)),
|
||||
),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: KcColors.surface,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: KcColors.blue, width: 1.5),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
),
|
||||
filledButtonTheme: FilledButtonThemeData(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: KcColors.blue,
|
||||
foregroundColor: Colors.white,
|
||||
minimumSize: const Size.fromHeight(50),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
textStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 15),
|
||||
),
|
||||
),
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: KcColors.blue,
|
||||
side: const BorderSide(color: KcColors.blue),
|
||||
minimumSize: const Size.fromHeight(46),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
),
|
||||
segmentedButtonTheme: SegmentedButtonThemeData(
|
||||
style: SegmentedButton.styleFrom(
|
||||
selectedBackgroundColor: KcColors.blue,
|
||||
selectedForegroundColor: Colors.white,
|
||||
foregroundColor: KcColors.navy,
|
||||
side: BorderSide(color: KcColors.navy.withValues(alpha: 0.15)),
|
||||
),
|
||||
),
|
||||
textTheme: const TextTheme(
|
||||
titleLarge: TextStyle(color: KcColors.navy, fontWeight: FontWeight.w700),
|
||||
titleMedium: TextStyle(color: KcColors.navy, fontWeight: FontWeight.w600),
|
||||
bodyMedium: TextStyle(color: KcColors.navy),
|
||||
bodySmall: TextStyle(color: KcColors.slate),
|
||||
labelMedium: TextStyle(color: KcColors.slate),
|
||||
),
|
||||
dividerTheme: DividerThemeData(color: KcColors.navy.withValues(alpha: 0.08)),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user