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
+56
View File
@@ -145,6 +145,44 @@ class GuestOverview {
);
}
enum WahlResultStatus { pending, assigned, unassigned }
class WahlResult {
WahlResult({
required this.wahlName,
required this.datumsSchluessel,
required this.teil,
required this.status,
required this.workshopName,
required this.wunschRang,
required this.isForced,
});
final String wahlName;
final String datumsSchluessel;
final String teil;
final WahlResultStatus status;
final String? workshopName;
final int? wunschRang;
final bool isForced;
factory WahlResult.fromJson(Map<String, dynamic> j) {
final wahl = j['wahl'] as Map<String, dynamic>;
return WahlResult(
wahlName: wahl['name'] as String,
datumsSchluessel: wahl['datumsSchluessel'] as String,
teil: wahl['teil'] as String,
status: switch (j['status'] as String?) {
'ASSIGNED' => WahlResultStatus.assigned,
'UNASSIGNED' => WahlResultStatus.unassigned,
_ => WahlResultStatus.pending,
},
workshopName: j['workshopName'] as String?,
wunschRang: (j['wunschRang'] as num?)?.toInt(),
isForced: j['isForced'] as bool? ?? false,
);
}
}
class FileEntry {
FileEntry({required this.id, required this.filename, required this.visibility});
final String id;
@@ -262,6 +300,11 @@ class Api {
await _post('/wahl/$wahlId/teilnehmer', {'prioritaeten': workshopIds});
}
Future<List<WahlResult>> guestWahlResults() async {
final list = await _get('/wahl/guest/results') as List<dynamic>;
return list.map((e) => WahlResult.fromJson(e as Map<String, dynamic>)).toList();
}
// --- files ---
Future<List<FileEntry>> files(String kcId) async {
final list = await _get('/files/$kcId') as List<dynamic>;
@@ -270,6 +313,19 @@ class Api {
String fileDownloadUrl(String fileId) => '$kApiBase/files/download/$fileId';
/// WebSocket endpoint for the chat gateway. It lives at `/chat` (outside the
/// `/api` prefix) and authenticates via a `?token=` query param.
Uri chatWsUri() {
final base = Uri.parse(kApiBase);
return Uri(
scheme: base.scheme == 'https' ? 'wss' : 'ws',
host: base.host,
port: base.hasPort ? base.port : null,
path: '/chat',
queryParameters: {'token': token ?? ''},
);
}
// --- chat (read-only for now; sending is a WebSocket-only path) ---
Future<List<ChatChannel>> channels(String kcId) async {
final list = await _get('/chat/$kcId/channels') as List<dynamic>;
+57
View File
@@ -0,0 +1,57 @@
import 'dart:async';
import 'dart:convert';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'api.dart';
/// Thin wrapper over the raw `ws` chat gateway. The NestJS `WsAdapter`
/// expects `{"event": ..., "data": ...}` frames in both directions.
class ChatSocket {
ChatSocket(this._uri);
final Uri _uri;
WebSocketChannel? _channel;
final _messages = StreamController<ChatMessage>.broadcast();
final _status = StreamController<String>.broadcast();
/// Incoming `chat:message` frames.
Stream<ChatMessage> get messages => _messages.stream;
/// "connected" / "closed" / "error: ..." for a small status line.
Stream<String> get status => _status.stream;
void connect(String channelId) {
_channel = WebSocketChannel.connect(_uri);
_channel!.stream.listen(
(raw) {
_status.add('connected');
try {
final frame = jsonDecode(raw as String) as Map<String, dynamic>;
if (frame['event'] == 'chat:message') {
_messages.add(ChatMessage.fromJson(frame['data'] as Map<String, dynamic>));
}
} catch (_) {
// ignore frames we don't model
}
},
onError: (Object e) => _status.add('error: $e'),
onDone: () => _status.add('closed'),
);
_send('chat:join', {'channelId': channelId});
}
void sendMessage(String channelId, String body) {
_send('chat:send', {'channelId': channelId, 'body': body});
}
void _send(String event, Map<String, dynamic> data) {
_channel?.sink.add(jsonEncode({'event': event, 'data': data}));
}
void dispose() {
_channel?.sink.close();
_messages.close();
_status.close();
}
}
+137 -60
View File
@@ -1,10 +1,12 @@
import 'package:flutter/material.dart';
import '../api.dart';
import '../chat_socket.dart';
import '../main.dart';
/// Read-only chat view. Sending a message is a WebSocket-only path on the
/// backend (`chat:send`); wiring that up is a follow-up.
/// Chat: channel list (REST) + a per-channel view that loads history over
/// REST and then streams live messages over the `/chat` WebSocket gateway
/// (`chat:join` / `chat:send` / `chat:message`).
class ChatScreen extends StatefulWidget {
const ChatScreen({super.key, required this.kcId});
final String kcId;
@@ -85,75 +87,150 @@ class _ChannelMessages extends StatefulWidget {
}
class _ChannelMessagesState extends State<_ChannelMessages> {
Future<List<ChatMessage>>? _future;
final List<ChatMessage> _messages = [];
final _composer = TextEditingController();
final _scroll = ScrollController();
ChatSocket? _socket;
bool _loading = true;
String? _loadError;
String _wsStatus = 'verbinde…';
@override
void didChangeDependencies() {
super.didChangeDependencies();
_future ??= AppScope.of(context).api.messages(widget.channelId);
if (_socket != null) return;
final api = AppScope.of(context).api;
_load(api);
_socket = ChatSocket(api.chatWsUri())
..connect(widget.channelId)
..messages.listen(_onIncoming)
..status.listen((s) => mounted ? setState(() => _wsStatus = s) : null);
}
Future<void> _load(Api api) async {
try {
final history = await api.messages(widget.channelId);
if (!mounted) return;
setState(() {
_messages
..clear()
..addAll(history);
_loading = false;
});
_jump();
} catch (e) {
if (mounted) setState(() { _loadError = '$e'; _loading = false; });
}
}
void _onIncoming(ChatMessage m) {
if (!mounted) return;
setState(() => _messages.add(m));
_jump();
}
void _jump() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scroll.hasClients) {
_scroll.jumpTo(_scroll.position.maxScrollExtent);
}
});
}
void _send() {
final text = _composer.text.trim();
if (text.isEmpty) return;
_socket?.sendMessage(widget.channelId, text);
_composer.clear();
}
@override
void dispose() {
_socket?.dispose();
_composer.dispose();
_scroll.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(widget.title)),
body: FutureBuilder<List<ChatMessage>>(
future: _future,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text('${snap.error}', textAlign: TextAlign.center),
),
);
}
final messages = snap.data!;
if (messages.isEmpty) {
return const Center(child: Text('Noch keine Nachrichten.'));
}
return ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: messages.length,
itemBuilder: (context, i) {
final m = messages[i];
return Align(
alignment: Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(m.body),
const SizedBox(height: 2),
Text(
m.createdAt,
style: Theme.of(context).textTheme.labelSmall,
),
],
),
),
);
},
);
},
),
bottomNavigationBar: const Padding(
padding: EdgeInsets.all(12),
child: Text(
'Senden folgt (WebSocket) — aktuell nur Lesen.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 12),
appBar: AppBar(
title: Text(widget.title),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(18),
child: Text('WebSocket: $_wsStatus', style: const TextStyle(fontSize: 11)),
),
),
body: Column(
children: [
Expanded(child: _body(context)),
SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.all(8),
child: Row(
children: [
Expanded(
child: TextField(
controller: _composer,
onSubmitted: (_) => _send(),
decoration: const InputDecoration(
hintText: 'Nachricht…',
border: OutlineInputBorder(),
isDense: true,
),
),
),
IconButton(icon: const Icon(Icons.send), onPressed: _send),
],
),
),
),
],
),
);
}
Widget _body(BuildContext context) {
if (_loading) return const Center(child: CircularProgressIndicator());
if (_loadError != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(_loadError!, textAlign: TextAlign.center),
),
);
}
if (_messages.isEmpty) {
return const Center(child: Text('Noch keine Nachrichten.'));
}
return ListView.builder(
controller: _scroll,
padding: const EdgeInsets.all(12),
itemCount: _messages.length,
itemBuilder: (context, i) {
final m = _messages[i];
return Align(
alignment: Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(m.body),
const SizedBox(height: 2),
Text(m.createdAt, style: Theme.of(context).textTheme.labelSmall),
],
),
),
);
},
);
}
}
+1 -1
View File
@@ -34,7 +34,7 @@ class HomeScreen extends StatelessWidget {
_NavTile(
icon: Icons.forum,
title: 'Chat',
subtitle: 'Kanäle & Nachrichten (lesen)',
subtitle: 'Kanäle, Verlauf & Live-Nachrichten',
onTap: () => _open(context, ChatScreen(kcId: kcId)),
),
];
+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),
),
);
}