Files
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

58 lines
1.6 KiB
Dart

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();
}
}