feat: client monorepo (Flutter app) + web fallback redesign #1

Merged
linus merged 37 commits from feat/backend-phases-0-6 into main 2026-09-12 11:27:27 +00:00
12 changed files with 999 additions and 61 deletions
Showing only changes of commit 2e3e62b896 - Show all commits
+1 -1
View File
@@ -34,7 +34,7 @@ export class FilesController {
/// Only the Leitungsteam uploads files (per KC), tagged with a visibility tier.
@Post(':kcId')
@UseGuards(AuthGuard('authentik'), RolesGuard)
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
@Roles(Role.LEITUNGSTEAM)
@UseInterceptors(FileInterceptor('file'))
upload(
+8 -8
View File
@@ -32,35 +32,35 @@ export class WahlController {
/// Wahl-/Workshop-/Force-Zuteilung-Verwaltung ist ausschließlich Sache des
/// Leitungsteams (global über alle KCs, siehe RolesGuard).
@Post()
@UseGuards(AuthGuard('authentik'), RolesGuard)
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
@Roles(Role.LEITUNGSTEAM)
createWahl(@Body() dto: CreateWahlDto) {
return this.wahl.createWahl(dto.kcId, dto.name, dto.datumsSchluessel, dto.teil);
}
@Get()
@UseGuards(AuthGuard('authentik'), RolesGuard)
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
@Roles(Role.LEITUNGSTEAM)
listWahlen(@Query('kcId') kcId: string) {
return this.wahl.listWahlen(kcId);
}
@Post(':wahlId/workshops')
@UseGuards(AuthGuard('authentik'), RolesGuard)
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
@Roles(Role.LEITUNGSTEAM)
createWorkshop(@Param('wahlId') wahlId: string, @Body() dto: CreateWorkshopDto) {
return this.wahl.createWorkshop(wahlId, dto.name, dto.kapazitaet, dto.minTeilnehmer);
}
@Get(':wahlId/workshops')
@UseGuards(AuthGuard('authentik'), RolesGuard)
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
@Roles(Role.LEITUNGSTEAM)
listWorkshops(@Param('wahlId') wahlId: string) {
return this.wahl.listWorkshops(wahlId);
}
@Post(':wahlId/force-zuteilung')
@UseGuards(AuthGuard('authentik'), RolesGuard)
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
@Roles(Role.LEITUNGSTEAM)
createForceZuteilung(
@Param('wahlId') wahlId: string,
@@ -99,21 +99,21 @@ export class WahlController {
}
@Post(':wahlId/zuteilung/run')
@UseGuards(AuthGuard('authentik'), RolesGuard)
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
@Roles(Role.LEITUNGSTEAM)
runZuteilung(@Param('wahlId') wahlId: string) {
return this.zuteilung.run(wahlId);
}
@Get(':wahlId/zuteilung')
@UseGuards(AuthGuard('authentik'), RolesGuard)
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
@Roles(Role.LEITUNGSTEAM)
getZuteilung(@Param('wahlId') wahlId: string) {
return this.zuteilung.getResults(wahlId);
}
@Get(':wahlId/zuteilung/csv')
@UseGuards(AuthGuard('authentik'), RolesGuard)
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
@Roles(Role.LEITUNGSTEAM)
async exportCsv(@Param('wahlId') wahlId: string, @Res() res: Response) {
const csv = await this.zuteilung.exportCsv(wahlId);
+3
View File
@@ -46,3 +46,6 @@ app.*.map.json
# Widget Preview related
.widget_preview/
# DevTools options
devtools_options.yaml
+195
View File
@@ -142,6 +142,108 @@ class OnboardingRequest {
}
}
class WahlAdmin {
WahlAdmin({
required this.id,
required this.name,
required this.datumsSchluessel,
required this.teil,
required this.isOpen,
});
final String id;
final String name;
final String datumsSchluessel;
final String teil;
final bool isOpen;
factory WahlAdmin.fromJson(Map<String, dynamic> j) => WahlAdmin(
id: j['id'] as String,
name: j['name'] as String,
datumsSchluessel: j['datumsSchluessel'] as String? ?? '',
teil: j['teil'] as String? ?? '',
isOpen: j['isOpen'] as bool? ?? true,
);
}
class WorkshopAdmin {
WorkshopAdmin({
required this.id,
required this.name,
required this.kapazitaet,
required this.minTeilnehmer,
});
final String id;
final String name;
final int kapazitaet;
final int minTeilnehmer;
factory WorkshopAdmin.fromJson(Map<String, dynamic> j) => WorkshopAdmin(
id: j['id'] as String,
name: j['name'] as String,
kapazitaet: (j['kapazitaet'] as num).toInt(),
minTeilnehmer: (j['minTeilnehmer'] as num?)?.toInt() ?? 0,
);
}
class ZuteilungRow {
ZuteilungRow({
required this.name,
required this.workshopName,
required this.wunschRang,
required this.isForced,
});
final String name;
final String? workshopName;
final int wunschRang;
final bool isForced;
factory ZuteilungRow.fromJson(Map<String, dynamic> j) {
final ga = (j['teilnehmer'] as Map<String, dynamic>?)?['guestAccount']
as Map<String, dynamic>? ??
const {};
return ZuteilungRow(
name: [ga['firstName'], ga['lastName']].whereType<String>().join(' ').trim(),
workshopName: (j['workshop'] as Map<String, dynamic>?)?['name'] as String?,
wunschRang: (j['wunschRang'] as num?)?.toInt() ?? -1,
isForced: j['isForced'] as bool? ?? false,
);
}
}
class TeamerAccount {
TeamerAccount({required this.id, required this.email, required this.name});
final String id;
final String email;
final String name;
factory TeamerAccount.fromJson(Map<String, dynamic> j) => TeamerAccount(
id: j['id'] as String,
email: j['email'] as String? ?? '',
name: [j['firstName'], j['lastName']].whereType<String>().join(' ').trim(),
);
}
class TeamerInvite {
TeamerInvite({
required this.id,
required this.token,
required this.email,
required this.usedCount,
required this.maxUses,
required this.revoked,
});
final String id;
final String token;
final String? email;
final int usedCount;
final int? maxUses;
final bool revoked;
factory TeamerInvite.fromJson(Map<String, dynamic> j) => TeamerInvite(
id: j['id'] as String,
token: j['token'] as String,
email: j['email'] as String?,
usedCount: (j['usedCount'] as num?)?.toInt() ?? 0,
maxUses: (j['maxUses'] as num?)?.toInt(),
revoked: j['revokedAt'] != null,
);
}
class Workshop {
Workshop({required this.id, required this.name, required this.kapazitaet});
final String id;
@@ -402,6 +504,99 @@ class Api {
Future<void> approveOnboarding(String id) => _post('/onboarding/requests/$id/approve', null);
Future<void> rejectOnboarding(String id) => _post('/onboarding/requests/$id/reject', null);
// --- LT Wahl administration ---
Future<List<WahlAdmin>> wahlenForKc(String kcId) async {
final list = await _get('/wahl?kcId=$kcId') as List<dynamic>;
return list.map((e) => WahlAdmin.fromJson(e as Map<String, dynamic>)).toList();
}
Future<void> createWahl(
String kcId,
String name,
String datumsSchluessel,
String teil,
) =>
_post('/wahl', {
'kcId': kcId,
'name': name,
'datumsSchluessel': datumsSchluessel,
'teil': teil,
});
Future<List<WorkshopAdmin>> workshopsForWahl(String wahlId) async {
final list = await _get('/wahl/$wahlId/workshops') as List<dynamic>;
return list.map((e) => WorkshopAdmin.fromJson(e as Map<String, dynamic>)).toList();
}
Future<void> createWorkshop(
String wahlId,
String name,
int kapazitaet,
int minTeilnehmer,
) =>
_post('/wahl/$wahlId/workshops', {
'name': name,
'kapazitaet': kapazitaet,
'minTeilnehmer': minTeilnehmer,
});
Future<void> runZuteilung(String wahlId) => _post('/wahl/$wahlId/zuteilung/run', null);
Future<List<ZuteilungRow>> zuteilungResults(String wahlId) async {
final list = await _get('/wahl/$wahlId/zuteilung') as List<dynamic>;
return list.map((e) => ZuteilungRow.fromJson(e as Map<String, dynamic>)).toList();
}
// --- Teamer administration (LT or the responsible Verantwortliche/r) ---
Future<List<TeamerAccount>> teamerFor(String gemeindeId) async {
final list = await _get('/gemeinde/$gemeindeId/teamer') as List<dynamic>;
return list.map((e) => TeamerAccount.fromJson(e as Map<String, dynamic>)).toList();
}
Future<void> createTeamer(
String gemeindeId, {
required String firstName,
required String lastName,
required String email,
required String password,
}) =>
_post('/gemeinde/$gemeindeId/teamer', {
'firstName': firstName,
'lastName': lastName,
'email': email,
'password': password,
});
Future<List<TeamerInvite>> teamerInvitesFor(String gemeindeId) async {
final list = await _get('/gemeinde/$gemeindeId/teamer-invites') as List<dynamic>;
return list.map((e) => TeamerInvite.fromJson(e as Map<String, dynamic>)).toList();
}
Future<TeamerInvite> createTeamerInvite(
String gemeindeId, {
String? email,
int? maxUses,
int? expiresInHours,
}) async =>
TeamerInvite.fromJson(await _post('/gemeinde/$gemeindeId/teamer-invites', {
if (email != null && email.isNotEmpty) 'email': email,
'maxUses': ?maxUses,
'expiresInHours': ?expiresInHours,
}) as Map<String, dynamic>);
// --- Verantwortlichen self-registration ---
Future<Map<String, dynamic>> resolveInvite(String inviteCode) async =>
await _get('/onboarding/kc/$inviteCode') as Map<String, dynamic>;
Future<Map<String, dynamic>> registerVerantwortliche(
String inviteCode,
String gemeindeId,
) async =>
await _post('/onboarding/verantwortliche', {
'inviteCode': inviteCode,
'gemeindeId': gemeindeId,
}) as Map<String, dynamic>;
// --- chat: REST for channels/history; live send/receive is the /chat WS ---
Future<List<ChatChannel>> channels(String kcId) async {
final list = await _get('/chat/$kcId/channels') as List<dynamic>;
+28 -46
View File
@@ -2,6 +2,9 @@ import 'package:flutter/material.dart';
import '../api.dart';
import '../main.dart';
import 'teamer_admin_screen.dart';
import 'ui.dart';
import 'wahl_admin_screen.dart';
/// Leitungsteam admin: KCs, their Gemeinden, and pending self-registrations.
class AdminScreen extends StatefulWidget {
@@ -24,13 +27,13 @@ class _AdminScreenState extends State<AdminScreen> {
Future<void> _createKc() async {
final api = AppScope.of(context).api;
final name = await _promptText(context, 'Neues KC', 'Name');
final name = await promptText(context, 'Neues KC', 'Name');
if (name == null || name.isEmpty || !mounted) return;
try {
await api.createKc(name);
if (mounted) _reload();
} catch (e) {
if (mounted) _toast(context, '$e');
if (mounted) toast(context, '$e');
}
}
@@ -109,13 +112,13 @@ class _KcDetailScreenState extends State<KcDetailScreen> {
Future<void> _addGemeinde() async {
final api = _api;
final name = await _promptText(context, 'Neue Gemeinde', 'Name');
final name = await promptText(context, 'Neue Gemeinde', 'Name');
if (name == null || name.isEmpty || !mounted) return;
try {
await api.createGemeinde(widget.kc.id, name);
if (mounted) _reloadGemeinden();
} catch (e) {
if (mounted) _toast(context, '$e');
if (mounted) toast(context, '$e');
}
}
@@ -133,20 +136,22 @@ class _KcDetailScreenState extends State<KcDetailScreen> {
trailing: const Icon(Icons.qr_code_2),
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: Text('Gemeinden',
style: Theme.of(context).textTheme.titleMedium),
Card(
child: ListTile(
leading: const Icon(Icons.how_to_vote),
title: const Text('Workshop-Wahlen'),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => WahlAdminScreen(kcId: widget.kc.id)),
),
TextButton.icon(
onPressed: _addGemeinde,
icon: const Icon(Icons.add),
label: const Text('Hinzufügen'),
),
],
),
),
const SizedBox(height: 16),
SectionHeader('Gemeinden', action: TextButton.icon(
onPressed: _addGemeinde,
icon: const Icon(Icons.add),
label: const Text('Hinzufügen'),
)),
_GemeindeList(future: _gemeinden!, onRetry: _reloadGemeinden),
const Divider(height: 40),
Text('Offene Verantwortlichen-Anfragen',
@@ -161,7 +166,7 @@ class _KcDetailScreenState extends State<KcDetailScreen> {
: await _api.rejectOnboarding(id);
_reloadRequests();
} catch (e) {
if (context.mounted) _toast(context, '$e');
if (context.mounted) toast(context, '$e');
}
},
),
@@ -199,6 +204,12 @@ class _GemeindeList extends StatelessWidget {
dense: true,
leading: const Icon(Icons.church),
title: Text(g.name),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => TeamerAdminScreen(gemeindeId: g.id, gemeindeName: g.name),
),
),
),
],
);
@@ -260,32 +271,3 @@ class _RequestList extends StatelessWidget {
}
}
Future<String?> _promptText(BuildContext context, String title, String label) {
final controller = TextEditingController();
return showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: TextField(
controller: controller,
autofocus: true,
decoration: InputDecoration(labelText: label),
onSubmitted: (v) => Navigator.of(context).pop(v.trim()),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Abbrechen'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(controller.text.trim()),
child: const Text('OK'),
),
],
),
);
}
void _toast(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
}
+12
View File
@@ -5,6 +5,7 @@ import '../main.dart';
import 'admin_screen.dart';
import 'chat_screen.dart';
import 'files_screen.dart';
import 'verantwortliche_register_screen.dart';
import 'wahl_screen.dart';
class HomeScreen extends StatelessWidget {
@@ -16,6 +17,10 @@ class HomeScreen extends StatelessWidget {
final id = state.identity!;
final kcId = id.kcId;
final needsVerantwRegistration = id.kind == SessionKind.user &&
!id.isLeitungsteam &&
id.memberships.isEmpty;
final tiles = <Widget>[
if (id.isLeitungsteam)
_NavTile(
@@ -24,6 +29,13 @@ class HomeScreen extends StatelessWidget {
subtitle: 'KCs, Gemeinden, Onboarding-Freigaben',
onTap: () => _open(context, const AdminScreen()),
),
if (needsVerantwRegistration)
_NavTile(
icon: Icons.how_to_reg,
title: 'Als Verantwortliche/r registrieren',
subtitle: 'KC-Code eingeben, Gemeinde wählen, Freigabe abwarten',
onTap: () => _open(context, const VerantwortlicheRegisterScreen()),
),
if (id.kind == SessionKind.guest)
_NavTile(
icon: Icons.how_to_vote,
@@ -0,0 +1,224 @@
import 'package:flutter/material.dart';
import '../api.dart';
import '../main.dart';
import 'ui.dart';
/// Teamer administration for one Gemeinde — usable by the Leitungsteam or the
/// responsible Gemeinde Verantwortliche/r.
class TeamerAdminScreen extends StatefulWidget {
const TeamerAdminScreen({
super.key,
required this.gemeindeId,
required this.gemeindeName,
});
final String gemeindeId;
final String gemeindeName;
@override
State<TeamerAdminScreen> createState() => _TeamerAdminScreenState();
}
class _TeamerAdminScreenState extends State<TeamerAdminScreen> {
Future<List<TeamerAccount>>? _teamer;
Future<List<TeamerInvite>>? _invites;
Api get _api => AppScope.of(context).api;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_teamer ??= _api.teamerFor(widget.gemeindeId);
_invites ??= _api.teamerInvitesFor(widget.gemeindeId);
}
void _reloadTeamer() =>
setState(() => _teamer = _api.teamerFor(widget.gemeindeId));
void _reloadInvites() =>
setState(() => _invites = _api.teamerInvitesFor(widget.gemeindeId));
Future<void> _addTeamer() async {
final api = _api;
final v = await showDialog<(String, String, String, String)>(
context: context,
builder: (_) => const _NewTeamerDialog(),
);
if (v == null || !mounted) return;
try {
await api.createTeamer(
widget.gemeindeId,
firstName: v.$1,
lastName: v.$2,
email: v.$3,
password: v.$4,
);
if (mounted) _reloadTeamer();
} catch (e) {
if (mounted) toast(context, '$e');
}
}
Future<void> _addInvite({required bool personal}) async {
final api = _api;
String? email;
if (personal) {
email = await promptText(context, 'E-Mail-Invite', 'E-Mail-Adresse');
if (email == null || email.isEmpty || !mounted) return;
}
try {
final inv = await api.createTeamerInvite(widget.gemeindeId, email: email);
if (!mounted) return;
_reloadInvites();
showDialog<void>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Invite erstellt'),
content: SelectableText(
personal
? 'E-Mail an ${inv.email} ausgelöst.\n\nToken: ${inv.token}'
: 'Gruppen-Link-Token (mehrfach nutzbar):\n\n${inv.token}',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
),
],
),
);
} catch (e) {
if (mounted) toast(context, '$e');
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Teamer:innen · ${widget.gemeindeName}')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
SectionHeader('Konten', action: TextButton.icon(
onPressed: _addTeamer,
icon: const Icon(Icons.person_add),
label: const Text('Anlegen'),
)),
FutureBuilder<List<TeamerAccount>>(
future: _teamer,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const LinearProgressIndicator();
}
if (snap.hasError) return Text('Fehler: ${snap.error}');
final list = snap.data!;
if (list.isEmpty) return const Text('Noch keine Teamer:innen.');
return Column(
children: [
for (final t in list)
ListTile(
dense: true,
leading: const Icon(Icons.person),
title: Text(t.name.isEmpty ? t.email : t.name),
subtitle: Text(t.email),
),
],
);
},
),
const Divider(height: 40),
SectionHeader('Einladungen', action: Wrap(
spacing: 4,
children: [
TextButton(
onPressed: () => _addInvite(personal: false),
child: const Text('Gruppen-Link'),
),
TextButton(
onPressed: () => _addInvite(personal: true),
child: const Text('per E-Mail'),
),
],
)),
FutureBuilder<List<TeamerInvite>>(
future: _invites,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const LinearProgressIndicator();
}
if (snap.hasError) return Text('Fehler: ${snap.error}');
final list = snap.data!;
if (list.isEmpty) return const Text('Keine Einladungen.');
return Column(
children: [
for (final i in list)
ListTile(
dense: true,
leading: Icon(i.revoked
? Icons.block
: i.email != null
? Icons.mail
: Icons.link),
title: Text(i.email ?? 'Gruppen-Link'),
subtitle: Text(
'${i.usedCount}${i.maxUses != null ? '/${i.maxUses}' : ''} genutzt'
'${i.revoked ? ' · widerrufen' : ''}',
),
trailing: SelectableText(
i.token.substring(0, 8),
style: Theme.of(context).textTheme.labelSmall,
),
),
],
);
},
),
],
),
);
}
}
class _NewTeamerDialog extends StatefulWidget {
const _NewTeamerDialog();
@override
State<_NewTeamerDialog> createState() => _NewTeamerDialogState();
}
class _NewTeamerDialogState extends State<_NewTeamerDialog> {
final _first = TextEditingController();
final _last = TextEditingController();
final _email = TextEditingController();
final _password = TextEditingController();
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Teamer:in anlegen'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(controller: _first, decoration: const InputDecoration(labelText: 'Vorname')),
TextField(controller: _last, decoration: const InputDecoration(labelText: 'Nachname')),
TextField(controller: _email, decoration: const InputDecoration(labelText: 'E-Mail')),
TextField(
controller: _password,
obscureText: true,
decoration: const InputDecoration(labelText: 'Passwort (min. 8)'),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(), child: const Text('Abbrechen')),
FilledButton(
onPressed: () => Navigator.of(context).pop((
_first.text.trim(),
_last.text.trim(),
_email.text.trim(),
_password.text,
)),
child: const Text('Anlegen'),
),
],
);
}
}
+77
View File
@@ -0,0 +1,77 @@
import 'package:flutter/material.dart';
/// Small shared widgets/helpers used across the admin screens.
void toast(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
}
class ErrorText extends StatelessWidget {
const ErrorText(this.message, {super.key, this.onRetry});
final String message;
final VoidCallback? onRetry;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(message, textAlign: TextAlign.center),
if (onRetry != null) ...[
const SizedBox(height: 12),
OutlinedButton(onPressed: onRetry, child: const Text('Erneut versuchen')),
],
],
),
),
);
}
}
class SectionHeader extends StatelessWidget {
const SectionHeader(this.title, {super.key, this.action});
final String title;
final Widget? action;
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: Text(title, style: Theme.of(context).textTheme.titleMedium),
),
?action,
],
);
}
}
/// Single-line text prompt dialog. Returns the trimmed value or null.
Future<String?> promptText(BuildContext context, String title, String label) {
final controller = TextEditingController();
return showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: TextField(
controller: controller,
autofocus: true,
decoration: InputDecoration(labelText: label),
onSubmitted: (v) => Navigator.of(context).pop(v.trim()),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Abbrechen'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(controller.text.trim()),
child: const Text('OK'),
),
],
),
);
}
@@ -0,0 +1,137 @@
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)),
],
],
],
),
),
),
);
}
}
@@ -0,0 +1,308 @@
import 'package:flutter/material.dart';
import '../api.dart';
import '../main.dart';
import 'ui.dart';
/// LT: manage the Wahlen of one KC — create, add workshops, run the
/// assignment algorithm, view the result.
class WahlAdminScreen extends StatefulWidget {
const WahlAdminScreen({super.key, required this.kcId});
final String kcId;
@override
State<WahlAdminScreen> createState() => _WahlAdminScreenState();
}
class _WahlAdminScreenState extends State<WahlAdminScreen> {
Future<List<WahlAdmin>>? _future;
Api get _api => AppScope.of(context).api;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_future ??= _api.wahlenForKc(widget.kcId);
}
void _reload() => setState(() => _future = _api.wahlenForKc(widget.kcId));
Future<void> _create() async {
final api = _api;
final v = await showDialog<(String, String, String)>(
context: context,
builder: (_) => const _NewWahlDialog(),
);
if (v == null || !mounted) return;
try {
await api.createWahl(widget.kcId, v.$1, v.$2, v.$3);
if (mounted) _reload();
} catch (e) {
if (mounted) toast(context, '$e');
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Wahlen')),
floatingActionButton: FloatingActionButton.extended(
onPressed: _create,
icon: const Icon(Icons.add),
label: const Text('Wahl'),
),
body: FutureBuilder<List<WahlAdmin>>(
future: _future,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) return ErrorText('${snap.error}', onRetry: _reload);
final wahlen = snap.data!;
if (wahlen.isEmpty) {
return const Center(child: Text('Noch keine Wahlen. Unten anlegen.'));
}
return ListView(
children: [
for (final w in wahlen)
ListTile(
leading: Icon(w.isOpen ? Icons.lock_open : Icons.lock),
title: Text(w.name),
subtitle: Text('${w.datumsSchluessel} · Teil ${w.teil}'),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => WahlDetailScreen(wahl: w)),
),
),
],
);
},
),
);
}
}
class WahlDetailScreen extends StatefulWidget {
const WahlDetailScreen({super.key, required this.wahl});
final WahlAdmin wahl;
@override
State<WahlDetailScreen> createState() => _WahlDetailScreenState();
}
class _WahlDetailScreenState extends State<WahlDetailScreen> {
Future<List<WorkshopAdmin>>? _workshops;
Future<List<ZuteilungRow>>? _results;
bool _running = false;
Api get _api => AppScope.of(context).api;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_workshops ??= _api.workshopsForWahl(widget.wahl.id);
_results ??= _api.zuteilungResults(widget.wahl.id);
}
void _reloadWorkshops() =>
setState(() => _workshops = _api.workshopsForWahl(widget.wahl.id));
void _reloadResults() =>
setState(() => _results = _api.zuteilungResults(widget.wahl.id));
Future<void> _addWorkshop() async {
final api = _api;
final v = await showDialog<(String, int, int)>(
context: context,
builder: (_) => const _NewWorkshopDialog(),
);
if (v == null || !mounted) return;
try {
await api.createWorkshop(widget.wahl.id, v.$1, v.$2, v.$3);
if (mounted) _reloadWorkshops();
} catch (e) {
if (mounted) toast(context, '$e');
}
}
Future<void> _run() async {
final api = _api;
setState(() => _running = true);
try {
await api.runZuteilung(widget.wahl.id);
if (mounted) _reloadResults();
} catch (e) {
if (mounted) toast(context, '$e');
} finally {
if (mounted) setState(() => _running = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(widget.wahl.name)),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
SectionHeader('Workshops', action: TextButton.icon(
onPressed: _addWorkshop,
icon: const Icon(Icons.add),
label: const Text('Hinzufügen'),
)),
FutureBuilder<List<WorkshopAdmin>>(
future: _workshops,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const LinearProgressIndicator();
}
if (snap.hasError) return Text('Fehler: ${snap.error}');
final ws = snap.data!;
if (ws.isEmpty) return const Text('Noch keine Workshops.');
return Column(
children: [
for (final w in ws)
ListTile(
dense: true,
leading: const Icon(Icons.groups),
title: Text(w.name),
subtitle: Text('Kapazität ${w.kapazitaet} · min. ${w.minTeilnehmer}'),
),
],
);
},
),
const Divider(height: 40),
Row(
children: [
Expanded(
child: Text('Zuteilung',
style: Theme.of(context).textTheme.titleMedium),
),
FilledButton.icon(
onPressed: _running ? null : _run,
icon: _running
? const SizedBox(
height: 16, width: 16, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.play_arrow),
label: const Text('Ausführen'),
),
],
),
const SizedBox(height: 8),
FutureBuilder<List<ZuteilungRow>>(
future: _results,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const LinearProgressIndicator();
}
if (snap.hasError) return Text('Fehler: ${snap.error}');
final rows = snap.data!;
if (rows.isEmpty) {
return const Text('Noch keine Zuteilung berechnet.');
}
return Column(
children: [
for (final r in rows)
ListTile(
dense: true,
title: Text(r.name),
subtitle: Text(r.workshopName ?? 'UNZUGETEILT'),
trailing: Text(
r.isForced
? 'fest'
: r.wunschRang > 0
? 'Wunsch ${r.wunschRang}'
: '',
),
),
],
);
},
),
],
),
);
}
}
class _NewWahlDialog extends StatefulWidget {
const _NewWahlDialog();
@override
State<_NewWahlDialog> createState() => _NewWahlDialogState();
}
class _NewWahlDialogState extends State<_NewWahlDialog> {
final _name = TextEditingController();
final _datum = TextEditingController();
final _teil = TextEditingController(text: '1');
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Neue Wahl'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(controller: _name, decoration: const InputDecoration(labelText: 'Name')),
TextField(
controller: _datum,
decoration: const InputDecoration(labelText: 'Datumsschlüssel (z. B. 2026-06-13)')),
TextField(controller: _teil, decoration: const InputDecoration(labelText: 'Teil')),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(), child: const Text('Abbrechen')),
FilledButton(
onPressed: () => Navigator.of(context).pop(
(_name.text.trim(), _datum.text.trim(), _teil.text.trim()),
),
child: const Text('Anlegen'),
),
],
);
}
}
class _NewWorkshopDialog extends StatefulWidget {
const _NewWorkshopDialog();
@override
State<_NewWorkshopDialog> createState() => _NewWorkshopDialogState();
}
class _NewWorkshopDialogState extends State<_NewWorkshopDialog> {
final _name = TextEditingController();
final _kap = TextEditingController(text: '12');
final _min = TextEditingController(text: '0');
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Neuer Workshop'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(controller: _name, decoration: const InputDecoration(labelText: 'Name')),
TextField(
controller: _kap,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'Kapazität')),
TextField(
controller: _min,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'Mindestteilnehmer')),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(), child: const Text('Abbrechen')),
FilledButton(
onPressed: () => Navigator.of(context).pop((
_name.text.trim(),
int.tryParse(_kap.text) ?? 0,
int.tryParse(_min.text) ?? 0,
)),
child: const Text('Anlegen'),
),
],
);
}
}
+3 -3
View File
@@ -18,18 +18,18 @@
<meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="A new Flutter project.">
<meta name="description" content="Konfi-Castle App">
<!-- iOS meta tags & icons -->
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="kc_app">
<meta name="apple-mobile-web-app-title" content="KC-App">
<link rel="apple-touch-icon" href="icons/Icon-192.png">
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/>
<title>kc_app</title>
<title>KC-App</title>
<link rel="manifest" href="manifest.json">
</head>
<body>
+3 -3
View File
@@ -1,11 +1,11 @@
{
"name": "kc_app",
"short_name": "kc_app",
"name": "KC-App",
"short_name": "KC-App",
"start_url": ".",
"display": "standalone",
"background_color": "#0175C2",
"theme_color": "#0175C2",
"description": "A new Flutter project.",
"description": "Konfi-Castle Event-, Wahl- und Kommunikationsplattform",
"orientation": "portrait-primary",
"prefer_related_applications": false,
"icons": [