import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; import 'oidc.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 Kc { Kc({required this.id, required this.name, required this.inviteCode, required this.isActive}); final String id; final String name; final String inviteCode; final bool isActive; factory Kc.fromJson(Map j) => Kc( id: j['id'] as String, name: j['name'] as String, inviteCode: j['inviteCode'] as String? ?? '', isActive: j['isActive'] as bool? ?? true, ); } class Gemeinde { Gemeinde({required this.id, required this.name, required this.kcId}); final String id; final String name; final String kcId; factory Gemeinde.fromJson(Map j) => Gemeinde( id: j['id'] as String, name: j['name'] as String, kcId: j['kcId'] as String? ?? '', ); } class OnboardingRequest { OnboardingRequest({ required this.id, required this.userName, required this.userEmail, required this.gemeindeName, }); final String id; final String userName; final String userEmail; final String gemeindeName; factory OnboardingRequest.fromJson(Map j) { final u = j['user'] as Map? ?? const {}; final g = j['gemeinde'] as Map? ?? const {}; return OnboardingRequest( id: j['id'] as String, userName: [u['firstName'], u['lastName']].whereType().join(' ').trim(), userEmail: u['email'] as String? ?? '', gemeindeName: g['name'] as String? ?? '', ); } } class WahlAdmin { WahlAdmin({ required this.id, required this.name, required this.datumsSchluessel, required this.teil, required this.isOpen, }); final String id; final String name; final String datumsSchluessel; final String teil; final bool isOpen; factory WahlAdmin.fromJson(Map j) => WahlAdmin( id: j['id'] as String, name: j['name'] as String, datumsSchluessel: j['datumsSchluessel'] as String? ?? '', teil: j['teil'] as String? ?? '', isOpen: j['isOpen'] as bool? ?? true, ); } class WorkshopAdmin { WorkshopAdmin({ required this.id, required this.name, required this.kapazitaet, required this.minTeilnehmer, }); final String id; final String name; final int kapazitaet; final int minTeilnehmer; factory WorkshopAdmin.fromJson(Map j) => WorkshopAdmin( id: j['id'] as String, name: j['name'] as String, kapazitaet: (j['kapazitaet'] as num).toInt(), minTeilnehmer: (j['minTeilnehmer'] as num?)?.toInt() ?? 0, ); } class ZuteilungRow { ZuteilungRow({ required this.name, required this.workshopName, required this.wunschRang, required this.isForced, }); final String name; final String? workshopName; final int wunschRang; final bool isForced; factory ZuteilungRow.fromJson(Map j) { final ga = (j['teilnehmer'] as Map?)?['guestAccount'] as Map? ?? const {}; return ZuteilungRow( name: [ga['firstName'], ga['lastName']].whereType().join(' ').trim(), workshopName: (j['workshop'] as Map?)?['name'] as String?, wunschRang: (j['wunschRang'] as num?)?.toInt() ?? -1, isForced: j['isForced'] as bool? ?? false, ); } } class TeamerAccount { TeamerAccount({required this.id, required this.email, required this.name}); final String id; final String email; final String name; factory TeamerAccount.fromJson(Map j) => TeamerAccount( id: j['id'] as String, email: j['email'] as String? ?? '', name: [j['firstName'], j['lastName']].whereType().join(' ').trim(), ); } class TeamerInvite { TeamerInvite({ required this.id, required this.token, required this.email, required this.usedCount, required this.maxUses, required this.revoked, }); final String id; final String token; final String? email; final int usedCount; final int? maxUses; final bool revoked; factory TeamerInvite.fromJson(Map j) => TeamerInvite( id: j['id'] as String, token: j['token'] as String, email: j['email'] as String?, usedCount: (j['usedCount'] as num?)?.toInt() ?? 0, maxUses: (j['maxUses'] as num?)?.toInt(), revoked: j['revokedAt'] != null, ); } 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 ?? ''}, ); } // --- LT admin --- Future> kcs() async { final list = await _get('/kc') as List; return list.map((e) => Kc.fromJson(e as Map)).toList(); } Future createKc(String name) async => Kc.fromJson(await _post('/kc', {'name': name}) as Map); Future> gemeinden(String kcId) async { final list = await _get('/gemeinde?kcId=$kcId') as List; return list.map((e) => Gemeinde.fromJson(e as Map)).toList(); } Future createGemeinde(String kcId, String name) async => Gemeinde.fromJson( await _post('/gemeinde', {'kcId': kcId, 'name': name}) as Map); Future> onboardingRequests(String kcId) async { final list = await _get('/onboarding/requests?kcId=$kcId') as List; return list.map((e) => OnboardingRequest.fromJson(e as Map)).toList(); } Future approveOnboarding(String id) => _post('/onboarding/requests/$id/approve', null); Future rejectOnboarding(String id) => _post('/onboarding/requests/$id/reject', null); // --- LT Wahl administration --- Future> wahlenForKc(String kcId) async { final list = await _get('/wahl?kcId=$kcId') as List; return list.map((e) => WahlAdmin.fromJson(e as Map)).toList(); } Future createWahl( String kcId, String name, String datumsSchluessel, String teil, ) => _post('/wahl', { 'kcId': kcId, 'name': name, 'datumsSchluessel': datumsSchluessel, 'teil': teil, }); Future> workshopsForWahl(String wahlId) async { final list = await _get('/wahl/$wahlId/workshops') as List; return list.map((e) => WorkshopAdmin.fromJson(e as Map)).toList(); } Future createWorkshop( String wahlId, String name, int kapazitaet, int minTeilnehmer, ) => _post('/wahl/$wahlId/workshops', { 'name': name, 'kapazitaet': kapazitaet, 'minTeilnehmer': minTeilnehmer, }); Future runZuteilung(String wahlId) => _post('/wahl/$wahlId/zuteilung/run', null); Future> zuteilungResults(String wahlId) async { final list = await _get('/wahl/$wahlId/zuteilung') as List; return list.map((e) => ZuteilungRow.fromJson(e as Map)).toList(); } // --- Teamer administration (LT or the responsible Verantwortliche/r) --- Future> teamerFor(String gemeindeId) async { final list = await _get('/gemeinde/$gemeindeId/teamer') as List; return list.map((e) => TeamerAccount.fromJson(e as Map)).toList(); } Future createTeamer( String gemeindeId, { required String firstName, required String lastName, required String email, required String password, }) => _post('/gemeinde/$gemeindeId/teamer', { 'firstName': firstName, 'lastName': lastName, 'email': email, 'password': password, }); Future> teamerInvitesFor(String gemeindeId) async { final list = await _get('/gemeinde/$gemeindeId/teamer-invites') as List; return list.map((e) => TeamerInvite.fromJson(e as Map)).toList(); } Future createTeamerInvite( String gemeindeId, { String? email, int? maxUses, int? expiresInHours, }) async => TeamerInvite.fromJson(await _post('/gemeinde/$gemeindeId/teamer-invites', { if (email != null && email.isNotEmpty) 'email': email, 'maxUses': ?maxUses, 'expiresInHours': ?expiresInHours, }) as Map); // --- Verantwortlichen self-registration --- Future> resolveInvite(String inviteCode) async => await _get('/onboarding/kc/$inviteCode') as Map; Future> registerVerantwortliche( String inviteCode, String gemeindeId, ) async => await _post('/onboarding/verantwortliche', { 'inviteCode': inviteCode, 'gemeindeId': gemeindeId, }) as Map; // --- chat: REST for channels/history; live send/receive is the /chat WS --- 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, {OidcClient? oidc}) : _oidc = oidc ?? OidcClient(http.Client()); final Api _api; final OidcClient _oidc; static const _tokenKey = 'kc_token'; static const _refreshKey = 'kc_refresh'; Identity? _identity; Identity? get identity => _identity; bool _loading = true; bool get loading => _loading; bool get isLoggedIn => _identity != null; String? _authError; String? get authError => _authError; Api get api => _api; Future bootstrap() async { final prefs = await SharedPreferences.getInstance(); // 1. Are we landing on the OIDC redirect (?code=…)? try { final tokens = await _oidc.completeIfCallback(); if (tokens != null) { await _establish(tokens.accessToken, refreshToken: tokens.refreshToken); _loading = false; notifyListeners(); return; } } catch (e) { _authError = '$e'; } // 2. Restore a stored session, refreshing an expired Authentik token. final saved = prefs.getString(_tokenKey); if (saved != null) { _api.token = saved; try { _identity = await _api.me(); } catch (_) { final refresh = prefs.getString(_refreshKey); if (refresh != null) { try { final t = await _oidc.refresh(refresh); await _establish(t.accessToken, refreshToken: t.refreshToken ?? refresh); } catch (_) { await _clear(prefs); } } else { await _clear(prefs); } } } _loading = false; notifyListeners(); } Future beginOidcLogin() => _oidc.beginLogin(); Future _establish(String token, {String? refreshToken}) async { _api.token = token; _identity = await _api.me(); final prefs = await SharedPreferences.getInstance(); await prefs.setString(_tokenKey, token); if (refreshToken != null) { await prefs.setString(_refreshKey, refreshToken); } _authError = null; notifyListeners(); } Future _clear(SharedPreferences prefs) async { _api.token = null; await prefs.remove(_tokenKey); await prefs.remove(_refreshKey); } 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 { _identity = null; _authError = null; await _clear(await SharedPreferences.getInstance()); notifyListeners(); } }