feat(client): Flutter app (web target) — Phase 7 start
Single Flutter codebase under client/app/ with web enabled (mobile/desktop can be added later; lib/ is platform-agnostic). Talks to the NestJS backend via a thin REST wrapper; API_BASE is a --dart-define (defaults to the local backend). Screens: - Login: Konfi/guest (invite code), local Teamer password login, Teamer invite redemption. Token persisted in shared_preferences, restored on start; GET /auth/me drives a role-aware home. - Workshop-Wahl (guests): loads /wahl/guest/overview, ordered pick of up to 3 workshops, submits to /wahl/:id/teilnehmer. - Dateien: /files/:kcId list. - Chat: channel + message list (read-only; WS send is a follow-up). State: AppState (ChangeNotifier) exposed via an InheritedNotifier (AppScope) — no third-party state package. flutter analyze clean, flutter build web --release passes, one widget smoke test. Also: interim client/web/ HTML placeholder stays as-is (per plan it is superseded by this Flutter web build). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,355 @@
|
||||
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(),
|
||||
);
|
||||
}
|
||||
|
||||
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});
|
||||
}
|
||||
|
||||
// --- 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';
|
||||
|
||||
// --- 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user