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:
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user