Files
KC-APP/client/app/lib/screens/wahl_screen.dart
T
linusandClaude Sonnet 5 e55faeaa95 feat: Wahl result view + live WebSocket chat in the Flutter client
Backend:
- fix(chat): ChatGateway stored the per-socket caller only after the async
  token check resolved, so a client that sent chat:join immediately on open
  raced ahead and got 4001. The caller is now stored as a promise that the
  message handlers await. Verified with a two-client send/receive E2E test
  against local Postgres.

Client (client/app/):
- Wahl screen gains a "Ergebnis" tab backed by GET /wahl/guest/results
  (PENDING / ASSIGNED with workshop + wish rank / UNASSIGNED).
- Chat channel view loads history over REST, then connects the /chat
  WebSocket (chat_socket.dart): live chat:message stream + a compose bar
  that sends chat:send. Shows the socket status.
- web_socket_channel dependency added.

flutter analyze/test/build web all green; backend npm test 56.

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

319 lines
9.3 KiB
Dart

import 'package:flutter/material.dart';
import '../api.dart';
import '../main.dart';
class WahlScreen extends StatefulWidget {
const WahlScreen({super.key});
@override
State<WahlScreen> createState() => _WahlScreenState();
}
class _WahlScreenState extends State<WahlScreen> {
Future<GuestOverview>? _future;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_future ??= AppScope.of(context).api.guestWahlOverview();
}
void _reload() {
setState(() {
_future = AppScope.of(context).api.guestWahlOverview();
});
}
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 2,
child: Scaffold(
appBar: AppBar(
title: const Text('Workshop-Wahl'),
bottom: const TabBar(tabs: [Tab(text: 'Wünsche'), Tab(text: 'Ergebnis')]),
),
body: TabBarView(
children: [
FutureBuilder<GuestOverview>(
future: _future,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) {
return _ErrorView(message: '${snap.error}', onRetry: _reload);
}
final data = snap.data!;
if (data.wahlen.isEmpty) {
return const Center(child: Text('Aktuell ist keine Wahl geöffnet.'));
}
return ListView(
padding: const EdgeInsets.all(16),
children: [
Text(data.kcName, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
for (final w in data.wahlen)
_WahlCard(wahl: w, onSubmitted: _reload),
],
);
},
),
const _ErgebnisTab(),
],
),
),
);
}
}
class _ErgebnisTab extends StatefulWidget {
const _ErgebnisTab();
@override
State<_ErgebnisTab> createState() => _ErgebnisTabState();
}
class _ErgebnisTabState extends State<_ErgebnisTab> {
Future<List<WahlResult>>? _future;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_future ??= AppScope.of(context).api.guestWahlResults();
}
void _reload() {
setState(() => _future = AppScope.of(context).api.guestWahlResults());
}
@override
Widget build(BuildContext context) {
return FutureBuilder<List<WahlResult>>(
future: _future,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) {
return _ErrorView(message: '${snap.error}', onRetry: _reload);
}
final results = snap.data!;
if (results.isEmpty) {
return const Center(child: Text('Noch keine Teilnahme an einer Wahl.'));
}
return RefreshIndicator(
onRefresh: () async => _reload(),
child: ListView(
padding: const EdgeInsets.all(16),
children: [for (final r in results) _ResultCard(result: r)],
),
);
},
);
}
}
class _ResultCard extends StatelessWidget {
const _ResultCard({required this.result});
final WahlResult result;
@override
Widget build(BuildContext context) {
final (label, color, detail) = switch (result.status) {
WahlResultStatus.assigned => (
result.workshopName ?? 'Zugeteilt',
Colors.green,
result.isForced
? 'Fest zugeteilt (Leitungsteam)'
: 'Wunsch ${result.wunschRang ?? '?'}',
),
WahlResultStatus.unassigned => (
'Kein Platz frei',
Theme.of(context).colorScheme.error,
'Bitte beim Leitungsteam melden.',
),
WahlResultStatus.pending => (
'Noch nicht zugeteilt',
Theme.of(context).colorScheme.outline,
'Die Zuteilung läuft noch.',
),
};
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: ListTile(
leading: Icon(Icons.emoji_events, color: color),
title: Text('${result.wahlName} · ${result.datumsSchluessel} Teil ${result.teil}',
style: Theme.of(context).textTheme.bodySmall),
subtitle: Text(label, style: Theme.of(context).textTheme.titleMedium),
trailing: Text(detail, textAlign: TextAlign.end),
),
);
}
}
class _WahlCard extends StatefulWidget {
const _WahlCard({required this.wahl, required this.onSubmitted});
final Wahl wahl;
final VoidCallback onSubmitted;
@override
State<_WahlCard> createState() => _WahlCardState();
}
class _WahlCardState extends State<_WahlCard> {
late final List<String> _picked = [...?widget.wahl.meinePrioritaeten];
bool _busy = false;
String? _error;
bool _done = false;
static const _maxPicks = 3;
void _toggle(String workshopId) {
setState(() {
if (_picked.contains(workshopId)) {
_picked.remove(workshopId);
} else if (_picked.length < _maxPicks) {
_picked.add(workshopId);
}
_done = false;
});
}
Future<void> _submit() async {
setState(() {
_busy = true;
_error = null;
});
try {
await AppScope.of(context).api.submitPrioritaeten(widget.wahl.id, _picked);
setState(() => _done = true);
widget.onSubmitted();
} on ApiException catch (e) {
setState(() => _error = e.message);
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
final w = widget.wahl;
return Card(
margin: const EdgeInsets.only(bottom: 16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(w.name, style: Theme.of(context).textTheme.titleLarge),
Text('${w.datumsSchluessel} · Teil ${w.teil}',
style: Theme.of(context).textTheme.bodySmall),
const SizedBox(height: 4),
Text(
'Tippe deine Wünsche in Reihenfolge an (max. $_maxPicks). '
'Die Zahl zeigt den Rang.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 8),
for (final ws in w.workshops)
_WorkshopRow(
workshop: ws,
rank: _picked.indexOf(ws.id),
enabled: !_busy,
onTap: () => _toggle(ws.id),
),
const SizedBox(height: 12),
if (_error != null) ...[
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
const SizedBox(height: 8),
],
Row(
children: [
FilledButton(
onPressed: (_busy || _picked.isEmpty) ? null : _submit,
child: _busy
? const SizedBox(
height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Wünsche absenden'),
),
const SizedBox(width: 12),
if (_done)
Row(
children: const [
Icon(Icons.check_circle, color: Colors.green, size: 20),
SizedBox(width: 4),
Text('Gespeichert'),
],
),
],
),
],
),
),
);
}
}
class _WorkshopRow extends StatelessWidget {
const _WorkshopRow({
required this.workshop,
required this.rank,
required this.enabled,
required this.onTap,
});
final Workshop workshop;
final int rank;
final bool enabled;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final selected = rank >= 0;
return ListTile(
dense: true,
enabled: enabled,
onTap: onTap,
leading: CircleAvatar(
radius: 14,
backgroundColor: selected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Text(
selected ? '${rank + 1}' : '',
style: TextStyle(
fontSize: 13,
color: selected ? Theme.of(context).colorScheme.onPrimary : null,
),
),
),
title: Text(workshop.name),
subtitle: Text('Kapazität ${workshop.kapazitaet}'),
trailing: Icon(selected ? Icons.check_box : Icons.check_box_outline_blank),
);
}
}
class _ErrorView extends StatelessWidget {
const _ErrorView({required this.message, required 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),
const SizedBox(height: 12),
OutlinedButton(onPressed: onRetry, child: const Text('Erneut versuchen')),
],
),
),
);
}
}