- New VerantwortlicheInvite model: LT-issued invites so a person can register as Gemeinde Verantwortliche(r) for a specific Gemeinde, skipping the self-registration approval step. - Wahl/Workshop/Teilnehmer gain phase support (phasenAnzahl, beschreibung), mirroring the WP plugin's multi-phase elections. Teilnehmer unique constraint now scoped per phase. - Auth: team login + guest auth adjustments, spec coverage. - sync.service.ts: register VerantwortlicheInvite as a synced model. - wahl.service.ts: submitTeilnehmer updated for the new phase-scoped unique key. - client: login/home screen rework, new theme.dart, FCM web tweaks. - .gitignore: ignore .DS_Store. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
808 lines
25 KiB
Dart
808 lines
25 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
import 'browser.dart' as browser;
|
|
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<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 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> j) {
|
|
final u = j['user'] as Map<String, dynamic>? ?? const {};
|
|
final g = j['gemeinde'] as Map<String, dynamic>? ?? const {};
|
|
return OnboardingRequest(
|
|
id: j['id'] as String,
|
|
userName: [u['firstName'], u['lastName']].whereType<String>().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<String, dynamic> 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<String, dynamic> 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 TeilnehmerRow {
|
|
TeilnehmerRow({
|
|
required this.id,
|
|
required this.name,
|
|
required this.prioritaeten,
|
|
required this.forcedWorkshopId,
|
|
});
|
|
final String id;
|
|
final String name;
|
|
final List<String> prioritaeten;
|
|
final String? forcedWorkshopId;
|
|
factory TeilnehmerRow.fromJson(Map<String, dynamic> j) => TeilnehmerRow(
|
|
id: j['id'] as String,
|
|
name: j['name'] as String? ?? '',
|
|
prioritaeten:
|
|
(j['prioritaeten'] as List<dynamic>? ?? []).map((e) => e as String).toList(),
|
|
forcedWorkshopId: j['forcedWorkshopId'] as String?,
|
|
);
|
|
}
|
|
|
|
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<String, dynamic> j) {
|
|
final ga = (j['teilnehmer'] as Map<String, dynamic>?)?['guestAccount']
|
|
as Map<String, dynamic>? ??
|
|
const {};
|
|
return ZuteilungRow(
|
|
name: [ga['firstName'], ga['lastName']].whereType<String>().join(' ').trim(),
|
|
workshopName: (j['workshop'] as Map<String, dynamic>?)?['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<String, dynamic> j) => TeamerAccount(
|
|
id: j['id'] as String,
|
|
email: j['email'] as String? ?? '',
|
|
name: [j['firstName'], j['lastName']].whereType<String>().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<String, dynamic> 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<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);
|
|
}
|
|
|
|
Future<dynamic> _patch(String path, Object? body) async {
|
|
final res = await _client.patch(
|
|
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;
|
|
}
|
|
|
|
/// Logs a Gemeinde Teamer in by Gemeinde name (the normal path) or by
|
|
/// email (legacy/personal accounts) — pass exactly one of the two.
|
|
Future<String> teamLogin({String? gemeindeName, String? email, required String password}) async {
|
|
final j = await _post('/auth/team-login', {
|
|
if (gemeindeName != null && gemeindeName.isNotEmpty) 'gemeindeName': gemeindeName,
|
|
if (email != null && email.isNotEmpty) '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 ?? ''},
|
|
);
|
|
}
|
|
|
|
// --- LT admin ---
|
|
Future<List<Kc>> kcs() async {
|
|
final list = await _get('/kc') as List<dynamic>;
|
|
return list.map((e) => Kc.fromJson(e as Map<String, dynamic>)).toList();
|
|
}
|
|
|
|
Future<Kc> createKc(String name) async =>
|
|
Kc.fromJson(await _post('/kc', {'name': name}) as Map<String, dynamic>);
|
|
|
|
Future<List<Gemeinde>> gemeinden(String kcId) async {
|
|
final list = await _get('/gemeinde?kcId=$kcId') as List<dynamic>;
|
|
return list.map((e) => Gemeinde.fromJson(e as Map<String, dynamic>)).toList();
|
|
}
|
|
|
|
Future<Gemeinde> createGemeinde(String kcId, String name) async => Gemeinde.fromJson(
|
|
await _post('/gemeinde', {'kcId': kcId, 'name': name}) as Map<String, dynamic>);
|
|
|
|
Future<List<OnboardingRequest>> onboardingRequests(String kcId) async {
|
|
final list = await _get('/onboarding/requests?kcId=$kcId') as List<dynamic>;
|
|
return list.map((e) => OnboardingRequest.fromJson(e as Map<String, dynamic>)).toList();
|
|
}
|
|
|
|
Future<void> approveOnboarding(String id) => _post('/onboarding/requests/$id/approve', null);
|
|
Future<void> rejectOnboarding(String id) => _post('/onboarding/requests/$id/reject', null);
|
|
|
|
// --- LT Wahl administration ---
|
|
Future<List<WahlAdmin>> wahlenForKc(String kcId) async {
|
|
final list = await _get('/wahl?kcId=$kcId') as List<dynamic>;
|
|
return list.map((e) => WahlAdmin.fromJson(e as Map<String, dynamic>)).toList();
|
|
}
|
|
|
|
Future<void> createWahl(
|
|
String kcId,
|
|
String name,
|
|
String datumsSchluessel,
|
|
String teil,
|
|
) =>
|
|
_post('/wahl', {
|
|
'kcId': kcId,
|
|
'name': name,
|
|
'datumsSchluessel': datumsSchluessel,
|
|
'teil': teil,
|
|
});
|
|
|
|
Future<List<WorkshopAdmin>> workshopsForWahl(String wahlId) async {
|
|
final list = await _get('/wahl/$wahlId/workshops') as List<dynamic>;
|
|
return list.map((e) => WorkshopAdmin.fromJson(e as Map<String, dynamic>)).toList();
|
|
}
|
|
|
|
Future<void> createWorkshop(
|
|
String wahlId,
|
|
String name,
|
|
int kapazitaet,
|
|
int minTeilnehmer,
|
|
) =>
|
|
_post('/wahl/$wahlId/workshops', {
|
|
'name': name,
|
|
'kapazitaet': kapazitaet,
|
|
'minTeilnehmer': minTeilnehmer,
|
|
});
|
|
|
|
Future<void> runZuteilung(String wahlId) => _post('/wahl/$wahlId/zuteilung/run', null);
|
|
|
|
Future<List<ZuteilungRow>> zuteilungResults(String wahlId) async {
|
|
final list = await _get('/wahl/$wahlId/zuteilung') as List<dynamic>;
|
|
return list.map((e) => ZuteilungRow.fromJson(e as Map<String, dynamic>)).toList();
|
|
}
|
|
|
|
Future<void> setWahlOpen(String wahlId, bool isOpen) =>
|
|
_patch('/wahl/$wahlId', {'isOpen': isOpen});
|
|
|
|
Future<List<TeilnehmerRow>> wahlTeilnehmer(String wahlId) async {
|
|
final list = await _get('/wahl/$wahlId/teilnehmer') as List<dynamic>;
|
|
return list.map((e) => TeilnehmerRow.fromJson(e as Map<String, dynamic>)).toList();
|
|
}
|
|
|
|
Future<void> forceZuteilung(String wahlId, String teilnehmerId, String workshopId) =>
|
|
_post('/wahl/$wahlId/force-zuteilung', {
|
|
'teilnehmerId': teilnehmerId,
|
|
'workshopId': workshopId,
|
|
});
|
|
|
|
Future<String> zuteilungCsv(String wahlId) async {
|
|
final res = await _get('/wahl/$wahlId/zuteilung/csv');
|
|
return res is String ? res : res.toString();
|
|
}
|
|
|
|
Future<void> uploadFile(
|
|
String kcId,
|
|
String filename,
|
|
List<int> bytes,
|
|
String visibility,
|
|
) async {
|
|
final req = http.MultipartRequest('POST', Uri.parse('$kApiBase/files/$kcId'))
|
|
..fields['visibility'] = visibility
|
|
..files.add(http.MultipartFile.fromBytes('file', bytes, filename: filename));
|
|
if (token != null) req.headers['Authorization'] = 'Bearer $token';
|
|
final streamed = await req.send();
|
|
final res = await http.Response.fromStream(streamed);
|
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
_decode(res); // throws ApiException with the server message
|
|
}
|
|
}
|
|
|
|
// --- Teamer administration (LT or the responsible Verantwortliche/r) ---
|
|
Future<List<TeamerAccount>> teamerFor(String gemeindeId) async {
|
|
final list = await _get('/gemeinde/$gemeindeId/teamer') as List<dynamic>;
|
|
return list.map((e) => TeamerAccount.fromJson(e as Map<String, dynamic>)).toList();
|
|
}
|
|
|
|
Future<void> 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<List<TeamerInvite>> teamerInvitesFor(String gemeindeId) async {
|
|
final list = await _get('/gemeinde/$gemeindeId/teamer-invites') as List<dynamic>;
|
|
return list.map((e) => TeamerInvite.fromJson(e as Map<String, dynamic>)).toList();
|
|
}
|
|
|
|
Future<TeamerInvite> 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<String, dynamic>);
|
|
|
|
// --- Verantwortlichen self-registration ---
|
|
Future<Map<String, dynamic>> resolveInvite(String inviteCode) async =>
|
|
await _get('/onboarding/kc/$inviteCode') as Map<String, dynamic>;
|
|
|
|
Future<Map<String, dynamic>> registerVerantwortliche(
|
|
String inviteCode,
|
|
String gemeindeId,
|
|
) async =>
|
|
await _post('/onboarding/verantwortliche', {
|
|
'inviteCode': inviteCode,
|
|
'gemeindeId': gemeindeId,
|
|
}) as Map<String, dynamic>;
|
|
|
|
// --- push ---
|
|
Future<void> registerDevice(String token, {String platform = 'web'}) =>
|
|
_post('/push/register', {'token': token, 'platform': platform});
|
|
|
|
// --- chat: REST for channels/history; live send/receive is the /chat WS ---
|
|
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, {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<void> 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<void> beginOidcLogin() => _oidc.beginLogin();
|
|
|
|
Future<void> _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();
|
|
_registerForPush(); // best-effort, fire and forget
|
|
}
|
|
|
|
Future<void> _registerForPush() async {
|
|
try {
|
|
final pushToken = await browser.getPushToken();
|
|
if (pushToken != null) await _api.registerDevice(pushToken);
|
|
} catch (_) {
|
|
// push is optional
|
|
}
|
|
}
|
|
|
|
Future<void> _clear(SharedPreferences prefs) async {
|
|
_api.token = null;
|
|
await prefs.remove(_tokenKey);
|
|
await prefs.remove(_refreshKey);
|
|
}
|
|
|
|
Future<void> guestLogin(String code, String first, String last) =>
|
|
_api.guestLogin(code, first, last).then(_establish);
|
|
|
|
Future<void> teamLogin({String? gemeindeName, String? email, required String password}) =>
|
|
_api.teamLogin(gemeindeName: gemeindeName, email: email, password: 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 {
|
|
_identity = null;
|
|
_authError = null;
|
|
await _clear(await SharedPreferences.getInstance());
|
|
notifyListeners();
|
|
}
|
|
}
|