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>
This commit is contained in:
2026-09-10 09:02:40 +02:00
co-authored by Claude Sonnet 5
parent 0b588fa4b7
commit e55faeaa95
8 changed files with 416 additions and 97 deletions
+119 -24
View File
@@ -27,31 +27,126 @@ class _WahlScreenState extends State<WahlScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Workshop-Wahl')),
body: 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(
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: [
Text(data.kcName, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
for (final w in data.wahlen)
_WahlCard(wahl: w, onSubmitted: _reload),
],
);
},
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),
),
);
}