Files
KC-APP/client/app/lib/screens/verantwortliche_register_screen.dart
T
linusandClaude Sonnet 5 2e3e62b896 feat(client): LT Wahl admin, Teamer admin, Verantwortlichen self-registration
New screens (client/app/lib/screens/):
- wahl_admin_screen.dart — per KC: list/create Wahlen; per Wahl: add
  workshops, run the assignment (POST /wahl/:id/zuteilung/run), view the
  result table.
- teamer_admin_screen.dart — per Gemeinde: list/create local Teamer
  accounts, create group-link or per-email invites (shows the token).
- verantwortliche_register_screen.dart — enter a KC invite code
  (GET /onboarding/kc/:code), pick a Gemeinde, submit
  (POST /onboarding/verantwortliche); shown on the home screen to a
  logged-in Authentik user who has no membership yet.
- ui.dart — shared toast / ErrorText / SectionHeader / promptText.
KcDetailScreen now links to Wahl admin and each Gemeinde row opens Teamer
admin.

Backend: widen the wahl + files LT routes to AuthGuard(['authentik','team'])
for consistency with the other LT controllers. Rebrand web/index.html +
manifest from "kc_app" to "KC-App".

Verified against local Postgres with an isLeitungsteam team token: create
KC/Gemeinde/Wahl/Workshop, run Zuteilung, create Teamer + invite, resolve
an invite code. flutter analyze/test/build web green; backend npm test 56.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 10:09:35 +02:00

138 lines
4.5 KiB
Dart

import 'package:flutter/material.dart';
import '../api.dart';
import '../main.dart';
/// Self-registration as a Gemeinde Verantwortliche/r: enter the KC invite
/// code, pick your Gemeinde, send the request. The result is a PENDING
/// membership a Leitungsteam member has to approve.
class VerantwortlicheRegisterScreen extends StatefulWidget {
const VerantwortlicheRegisterScreen({super.key});
@override
State<VerantwortlicheRegisterScreen> createState() =>
_VerantwortlicheRegisterScreenState();
}
class _VerantwortlicheRegisterScreenState
extends State<VerantwortlicheRegisterScreen> {
final _code = TextEditingController();
String? _kcName;
List<(String id, String name)> _gemeinden = [];
String? _selectedGemeinde;
bool _busy = false;
String? _error;
String? _done;
Api get _api => AppScope.of(context).api;
Future<void> _resolve() async {
setState(() {
_busy = true;
_error = null;
_kcName = null;
_gemeinden = [];
});
try {
final res = await _api.resolveInvite(_code.text.trim());
setState(() {
_kcName = res['kcName'] as String?;
_gemeinden = ((res['gemeinden'] as List<dynamic>?) ?? [])
.map((g) => (g['id'] as String, g['name'] as String))
.toList();
});
} catch (e) {
setState(() => _error = '$e');
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _submit() async {
if (_selectedGemeinde == null) return;
setState(() {
_busy = true;
_error = null;
});
try {
final res =
await _api.registerVerantwortliche(_code.text.trim(), _selectedGemeinde!);
setState(() => _done =
'Anfrage gesendet (Status: ${res['status']}). Ein Leitungsteam-Mitglied '
'muss dich noch freischalten.');
} catch (e) {
setState(() => _error = '$e');
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Als Verantwortliche/r registrieren')),
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: ListView(
padding: const EdgeInsets.all(24),
children: [
if (_done != null) ...[
const Icon(Icons.check_circle, color: Colors.green, size: 48),
const SizedBox(height: 12),
Text(_done!, textAlign: TextAlign.center),
const SizedBox(height: 20),
FilledButton(
onPressed: () => AppScope.of(context).logout(),
child: const Text('Abmelden'),
),
] else ...[
TextField(
controller: _code,
decoration: const InputDecoration(
labelText: 'KC-Einladungscode',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
OutlinedButton(
onPressed: _busy ? null : _resolve,
child: const Text('KC suchen'),
),
if (_kcName != null) ...[
const SizedBox(height: 20),
Text('KC: $_kcName',
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
DropdownButtonFormField<String>(
initialValue: _selectedGemeinde,
decoration: const InputDecoration(
labelText: 'Deine Gemeinde',
border: OutlineInputBorder(),
),
items: [
for (final g in _gemeinden)
DropdownMenuItem(value: g.$1, child: Text(g.$2)),
],
onChanged: (v) => setState(() => _selectedGemeinde = v),
),
const SizedBox(height: 16),
FilledButton(
onPressed: (_busy || _selectedGemeinde == null) ? null : _submit,
child: const Text('Anfrage senden'),
),
],
if (_error != null) ...[
const SizedBox(height: 16),
Text(_error!,
style: TextStyle(color: Theme.of(context).colorScheme.error)),
],
],
],
),
),
),
);
}
}