Files
KC-APP/client/app/lib/api.dart
T
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

412 lines
12 KiB
Dart

import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
/// Backend base URL. Override at build/run time with
/// `--dart-define=API_BASE=https://...`.
const String kApiBase = String.fromEnvironment(
'API_BASE',
defaultValue: 'http://localhost:3000/api',
);
class ApiException implements Exception {
ApiException(this.statusCode, this.message);
final int statusCode;
final String message;
@override
String toString() => 'ApiException($statusCode): $message';
}
enum SessionKind { guest, user }
/// Whatever `GET /auth/me` told us about the current token.
class Identity {
Identity({
required this.kind,
this.guestId,
this.userId,
this.email,
this.kcId,
this.gemeindeId,
this.isLeitungsteam = false,
this.memberships = const [],
});
final SessionKind kind;
final String? guestId;
final String? userId;
final String? email;
final String? kcId;
final String? gemeindeId;
final bool isLeitungsteam;
final List<Membership> memberships;
factory Identity.fromJson(Map<String, dynamic> j) {
if (j['kind'] == 'guest') {
return Identity(
kind: SessionKind.guest,
guestId: j['guestId'] as String?,
kcId: j['kcId'] as String?,
gemeindeId: j['gemeindeId'] as String?,
);
}
final ms = (j['memberships'] as List<dynamic>? ?? [])
.map((m) => Membership.fromJson(m as Map<String, dynamic>))
.toList();
return Identity(
kind: SessionKind.user,
userId: j['userId'] as String?,
email: j['email'] as String?,
isLeitungsteam: j['isLeitungsteam'] as bool? ?? false,
memberships: ms,
kcId: ms.isNotEmpty ? ms.first.kcId : null,
gemeindeId: ms.isNotEmpty ? ms.first.gemeindeId : null,
);
}
String get roleLabel {
if (kind == SessionKind.guest) return 'Konfi / Gast';
if (isLeitungsteam) return 'Leitungsteam';
if (memberships.any((m) => m.role == 'GEMEINDE_VERANTWORTLICHER')) {
return 'Gemeinde Verantwortliche/r';
}
if (memberships.any((m) => m.role == 'GEMEINDE_TEAMER')) {
return 'Gemeinde Teamer:in';
}
return 'Angemeldet (ohne Rolle)';
}
}
class Membership {
Membership({required this.kcId, this.gemeindeId, required this.role});
final String kcId;
final String? gemeindeId;
final String role;
factory Membership.fromJson(Map<String, dynamic> j) => Membership(
kcId: j['kcId'] as String,
gemeindeId: j['gemeindeId'] as String?,
role: j['role'] as String,
);
}
class Workshop {
Workshop({required this.id, required this.name, required this.kapazitaet});
final String id;
final String name;
final int kapazitaet;
factory Workshop.fromJson(Map<String, dynamic> j) => Workshop(
id: j['id'] as String,
name: j['name'] as String,
kapazitaet: (j['kapazitaet'] as num).toInt(),
);
}
class Wahl {
Wahl({
required this.id,
required this.name,
required this.datumsSchluessel,
required this.teil,
required this.workshops,
required this.meinePrioritaeten,
});
final String id;
final String name;
final String datumsSchluessel;
final String teil;
final List<Workshop> workshops;
final List<String>? meinePrioritaeten;
factory Wahl.fromJson(Map<String, dynamic> j) => Wahl(
id: j['id'] as String,
name: j['name'] as String,
datumsSchluessel: j['datumsSchluessel'] as String,
teil: j['teil'] as String,
workshops: (j['workshops'] as List<dynamic>)
.map((w) => Workshop.fromJson(w as Map<String, dynamic>))
.toList(),
meinePrioritaeten: (j['meinePrioritaeten'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
);
}
class GuestOverview {
GuestOverview({required this.kcName, required this.wahlen});
final String kcName;
final List<Wahl> wahlen;
factory GuestOverview.fromJson(Map<String, dynamic> j) => GuestOverview(
kcName: (j['kc'] as Map<String, dynamic>?)?['name'] as String? ?? '',
wahlen: (j['wahlen'] as List<dynamic>)
.map((w) => Wahl.fromJson(w as Map<String, dynamic>))
.toList(),
);
}
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;
final String filename;
final String visibility;
factory FileEntry.fromJson(Map<String, dynamic> j) => FileEntry(
id: j['id'] as String,
filename: j['filename'] as String,
visibility: j['visibility'] as String? ?? '',
);
}
class ChatChannel {
ChatChannel({required this.id, required this.type});
final String id;
final String type;
factory ChatChannel.fromJson(Map<String, dynamic> j) => ChatChannel(
id: j['id'] as String,
type: j['type'] as String? ?? '',
);
}
class ChatMessage {
ChatMessage({required this.body, required this.createdAt});
final String body;
final String createdAt;
factory ChatMessage.fromJson(Map<String, dynamic> j) => ChatMessage(
body: j['body'] as String? ?? '',
createdAt: j['createdAt'] as String? ?? '',
);
}
/// Thin REST wrapper. Holds the bearer token for the current session.
class Api {
Api(this._client);
final http.Client _client;
String? token;
Map<String, String> get _headers => {
'Content-Type': 'application/json',
if (token != null) 'Authorization': 'Bearer $token',
};
Future<dynamic> _get(String path) async {
final res = await _client.get(Uri.parse('$kApiBase$path'), headers: _headers);
return _decode(res);
}
Future<dynamic> _post(String path, Object? body) async {
final res = await _client.post(
Uri.parse('$kApiBase$path'),
headers: _headers,
body: body == null ? null : jsonEncode(body),
);
return _decode(res);
}
dynamic _decode(http.Response res) {
final text = res.body.isEmpty ? '{}' : res.body;
dynamic parsed;
try {
parsed = jsonDecode(text);
} catch (_) {
parsed = text;
}
if (res.statusCode >= 200 && res.statusCode < 300) return parsed;
final msg = parsed is Map && parsed['message'] != null
? (parsed['message'] is List
? (parsed['message'] as List).join(', ')
: parsed['message'].toString())
: 'HTTP ${res.statusCode}';
throw ApiException(res.statusCode, msg);
}
// --- auth ---
Future<String> guestLogin(String inviteCode, String firstName, String lastName) async {
final j = await _post('/auth/guest', {
'inviteCode': inviteCode,
'firstName': firstName,
'lastName': lastName,
});
return j['accessToken'] as String;
}
Future<String> teamLogin(String email, String password) async {
final j = await _post('/auth/team-login', {'email': email, 'password': password});
return j['accessToken'] as String;
}
Future<String> redeemTeamerInvite({
required String inviteToken,
required String firstName,
required String lastName,
required String password,
String? email,
}) async {
final j = await _post('/auth/teamer/register', {
'token': inviteToken,
'firstName': firstName,
'lastName': lastName,
'password': password,
if (email != null && email.isNotEmpty) 'email': email,
});
return j['accessToken'] as String;
}
Future<Identity> me() async =>
Identity.fromJson(await _get('/auth/me') as Map<String, dynamic>);
// --- guest Wahl ---
Future<GuestOverview> guestWahlOverview() async =>
GuestOverview.fromJson(await _get('/wahl/guest/overview') as Map<String, dynamic>);
Future<void> submitPrioritaeten(String wahlId, List<String> workshopIds) async {
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>;
return list.map((e) => FileEntry.fromJson(e as Map<String, dynamic>)).toList();
}
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>;
return list.map((e) => ChatChannel.fromJson(e as Map<String, dynamic>)).toList();
}
Future<List<ChatMessage>> messages(String channelId) async {
final list = await _get('/chat/channels/$channelId/messages') as List<dynamic>;
return list.map((e) => ChatMessage.fromJson(e as Map<String, dynamic>)).toList();
}
}
/// App-wide session + auth actions. Persists the token in shared_preferences
/// (localStorage on web).
class AppState extends ChangeNotifier {
AppState(this._api);
final Api _api;
static const _tokenKey = 'kc_token';
Identity? _identity;
Identity? get identity => _identity;
bool _loading = true;
bool get loading => _loading;
bool get isLoggedIn => _identity != null;
Api get api => _api;
Future<void> bootstrap() async {
final prefs = await SharedPreferences.getInstance();
final saved = prefs.getString(_tokenKey);
if (saved != null) {
_api.token = saved;
try {
_identity = await _api.me();
} catch (_) {
_api.token = null;
await prefs.remove(_tokenKey);
}
}
_loading = false;
notifyListeners();
}
Future<void> _establish(String token) async {
_api.token = token;
_identity = await _api.me();
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_tokenKey, token);
notifyListeners();
}
Future<void> guestLogin(String code, String first, String last) =>
_api.guestLogin(code, first, last).then(_establish);
Future<void> teamLogin(String email, String password) =>
_api.teamLogin(email, password).then(_establish);
Future<void> redeemInvite({
required String token,
required String first,
required String last,
required String password,
String? email,
}) =>
_api
.redeemTeamerInvite(
inviteToken: token,
firstName: first,
lastName: last,
password: password,
email: email,
)
.then(_establish);
Future<void> logout() async {
_api.token = null;
_identity = null;
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_tokenKey);
notifyListeners();
}
}