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 memberships; factory Identity.fromJson(Map 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? ?? []) .map((m) => Membership.fromJson(m as Map)) .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 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 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 workshops; final List? meinePrioritaeten; factory Wahl.fromJson(Map 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) .map((w) => Workshop.fromJson(w as Map)) .toList(), meinePrioritaeten: (j['meinePrioritaeten'] as List?) ?.map((e) => e as String) .toList(), ); } class GuestOverview { GuestOverview({required this.kcName, required this.wahlen}); final String kcName; final List wahlen; factory GuestOverview.fromJson(Map j) => GuestOverview( kcName: (j['kc'] as Map?)?['name'] as String? ?? '', wahlen: (j['wahlen'] as List) .map((w) => Wahl.fromJson(w as Map)) .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 j) { final wahl = j['wahl'] as Map; 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 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 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 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 get _headers => { 'Content-Type': 'application/json', if (token != null) 'Authorization': 'Bearer $token', }; Future _get(String path) async { final res = await _client.get(Uri.parse('$kApiBase$path'), headers: _headers); return _decode(res); } Future _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 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 teamLogin(String email, String password) async { final j = await _post('/auth/team-login', {'email': email, 'password': password}); return j['accessToken'] as String; } Future 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 me() async => Identity.fromJson(await _get('/auth/me') as Map); // --- guest Wahl --- Future guestWahlOverview() async => GuestOverview.fromJson(await _get('/wahl/guest/overview') as Map); Future submitPrioritaeten(String wahlId, List workshopIds) async { await _post('/wahl/$wahlId/teilnehmer', {'prioritaeten': workshopIds}); } Future> guestWahlResults() async { final list = await _get('/wahl/guest/results') as List; return list.map((e) => WahlResult.fromJson(e as Map)).toList(); } // --- files --- Future> files(String kcId) async { final list = await _get('/files/$kcId') as List; return list.map((e) => FileEntry.fromJson(e as Map)).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> channels(String kcId) async { final list = await _get('/chat/$kcId/channels') as List; return list.map((e) => ChatChannel.fromJson(e as Map)).toList(); } Future> messages(String channelId) async { final list = await _get('/chat/channels/$channelId/messages') as List; return list.map((e) => ChatMessage.fromJson(e as Map)).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 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 _establish(String token) async { _api.token = token; _identity = await _api.me(); final prefs = await SharedPreferences.getInstance(); await prefs.setString(_tokenKey, token); notifyListeners(); } Future guestLogin(String code, String first, String last) => _api.guestLogin(code, first, last).then(_establish); Future teamLogin(String email, String password) => _api.teamLogin(email, password).then(_establish); Future 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 logout() async { _api.token = null; _identity = null; final prefs = await SharedPreferences.getInstance(); await prefs.remove(_tokenKey); notifyListeners(); } }