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

237 lines
6.7 KiB
Dart

import 'package:flutter/material.dart';
import '../api.dart';
import '../chat_socket.dart';
import '../main.dart';
/// 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;
@override
State<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> {
Future<List<ChatChannel>>? _future;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_future ??= AppScope.of(context).api.channels(widget.kcId);
}
static const _typeLabels = {
'GEMEINDE_GRUPPE': 'Gemeinde-Gruppe',
'DIREKT': 'Direktnachricht',
'LT_UEBERGREIFEND': 'Leitungsteam',
'BROADCAST': 'Ankündigungen',
};
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Chat')),
body: FutureBuilder<List<ChatChannel>>(
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 channels = snap.data!;
if (channels.isEmpty) {
return const Center(child: Text('Keine Kanäle sichtbar.'));
}
return ListView(
children: [
for (final c in channels)
ListTile(
leading: const Icon(Icons.tag),
title: Text(_typeLabels[c.type] ?? c.type),
subtitle: Text(c.id),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => _ChannelMessages(
channelId: c.id,
title: _typeLabels[c.type] ?? c.type,
),
),
),
),
],
);
},
),
);
}
}
class _ChannelMessages extends StatefulWidget {
const _ChannelMessages({required this.channelId, required this.title});
final String channelId;
final String title;
@override
State<_ChannelMessages> createState() => _ChannelMessagesState();
}
class _ChannelMessagesState extends State<_ChannelMessages> {
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();
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),
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),
],
),
),
);
},
);
}
}