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:
2026-09-10 08:52:36 +02:00
co-authored by Claude Sonnet 5
parent 7a95f4098f
commit 0886424526
21 changed files with 1830 additions and 0 deletions
+159
View File
@@ -0,0 +1,159 @@
import 'package:flutter/material.dart';
import '../api.dart';
import '../main.dart';
/// Read-only chat view. Sending a message is a WebSocket-only path on the
/// backend (`chat:send`); wiring that up is a follow-up.
class ChatScreen extends StatefulWidget {
const ChatScreen({super.key, required this.kcId});
final String kcId;
@override
State<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> {
Future<List<ChatChannel>>? _future;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_future ??= AppScope.of(context).api.channels(widget.kcId);
}
static const _typeLabels = {
'GEMEINDE_GRUPPE': 'Gemeinde-Gruppe',
'DIREKT': 'Direktnachricht',
'LT_UEBERGREIFEND': 'Leitungsteam',
'BROADCAST': 'Ankündigungen',
};
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Chat')),
body: FutureBuilder<List<ChatChannel>>(
future: _future,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text('${snap.error}', textAlign: TextAlign.center),
),
);
}
final channels = snap.data!;
if (channels.isEmpty) {
return const Center(child: Text('Keine Kanäle sichtbar.'));
}
return ListView(
children: [
for (final c in channels)
ListTile(
leading: const Icon(Icons.tag),
title: Text(_typeLabels[c.type] ?? c.type),
subtitle: Text(c.id),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => _ChannelMessages(
channelId: c.id,
title: _typeLabels[c.type] ?? c.type,
),
),
),
),
],
);
},
),
);
}
}
class _ChannelMessages extends StatefulWidget {
const _ChannelMessages({required this.channelId, required this.title});
final String channelId;
final String title;
@override
State<_ChannelMessages> createState() => _ChannelMessagesState();
}
class _ChannelMessagesState extends State<_ChannelMessages> {
Future<List<ChatMessage>>? _future;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_future ??= AppScope.of(context).api.messages(widget.channelId);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(widget.title)),
body: FutureBuilder<List<ChatMessage>>(
future: _future,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text('${snap.error}', textAlign: TextAlign.center),
),
);
}
final messages = snap.data!;
if (messages.isEmpty) {
return const Center(child: Text('Noch keine Nachrichten.'));
}
return ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: messages.length,
itemBuilder: (context, i) {
final m = messages[i];
return Align(
alignment: Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(m.body),
const SizedBox(height: 2),
Text(
m.createdAt,
style: Theme.of(context).textTheme.labelSmall,
),
],
),
),
);
},
);
},
),
bottomNavigationBar: const Padding(
padding: EdgeInsets.all(12),
child: Text(
'Senden folgt (WebSocket) — aktuell nur Lesen.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 12),
),
),
);
}
}
+77
View File
@@ -0,0 +1,77 @@
import 'package:flutter/material.dart';
import '../api.dart';
import '../main.dart';
class FilesScreen extends StatefulWidget {
const FilesScreen({super.key, required this.kcId});
final String kcId;
@override
State<FilesScreen> createState() => _FilesScreenState();
}
class _FilesScreenState extends State<FilesScreen> {
Future<List<FileEntry>>? _future;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_future ??= AppScope.of(context).api.files(widget.kcId);
}
static const _visibilityLabels = {
'ALLE': 'Alle',
'ALLE_AUSSER_KONFIS': 'Team (ohne Konfis)',
'NUR_LT': 'Nur Leitungsteam',
};
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Dateien')),
body: FutureBuilder<List<FileEntry>>(
future: _future,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text('${snap.error}', textAlign: TextAlign.center),
),
);
}
final files = snap.data!;
if (files.isEmpty) {
return const Center(child: Text('Keine Dateien freigegeben.'));
}
return ListView.separated(
itemCount: files.length,
separatorBuilder: (context, index) => const Divider(height: 1),
itemBuilder: (context, i) {
final f = files[i];
return ListTile(
leading: const Icon(Icons.insert_drive_file_outlined),
title: Text(f.filename),
subtitle: Text(_visibilityLabels[f.visibility] ?? f.visibility),
trailing: const Icon(Icons.download),
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Download-URL: ${AppScope.of(context).api.fileDownloadUrl(f.id)}',
),
),
);
},
);
},
);
},
),
);
}
}
+139
View File
@@ -0,0 +1,139 @@
import 'package:flutter/material.dart';
import '../api.dart';
import '../main.dart';
import 'chat_screen.dart';
import 'files_screen.dart';
import 'wahl_screen.dart';
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
final state = AppScope.of(context);
final id = state.identity!;
final kcId = id.kcId;
final tiles = <Widget>[
if (id.kind == SessionKind.guest)
_NavTile(
icon: Icons.how_to_vote,
title: 'Workshop-Wahl',
subtitle: 'Deine Wünsche abgeben',
onTap: () => _open(context, const WahlScreen()),
),
if (kcId != null)
_NavTile(
icon: Icons.folder_shared,
title: 'Dateien',
subtitle: 'Freigegebene Dateien ansehen',
onTap: () => _open(context, FilesScreen(kcId: kcId)),
),
if (kcId != null)
_NavTile(
icon: Icons.forum,
title: 'Chat',
subtitle: 'Kanäle & Nachrichten (lesen)',
onTap: () => _open(context, ChatScreen(kcId: kcId)),
),
];
return Scaffold(
appBar: AppBar(
title: const Text('KC-App'),
actions: [
IconButton(
tooltip: 'Abmelden',
onPressed: state.logout,
icon: const Icon(Icons.logout),
),
],
),
body: SafeArea(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: ListView(
padding: const EdgeInsets.all(20),
children: [
_IdentityCard(id: id),
const SizedBox(height: 16),
...tiles,
if (tiles.isEmpty)
const Padding(
padding: EdgeInsets.only(top: 24),
child: Text(
'Für diesen Account gibt es hier noch keine Ansichten. '
'Sobald dir eine Gemeinde/ein KC zugeordnet ist, erscheinen '
'Dateien und Chat.',
),
),
],
),
),
),
),
);
}
void _open(BuildContext context, Widget screen) {
Navigator.of(context).push(MaterialPageRoute(builder: (_) => screen));
}
}
class _IdentityCard extends StatelessWidget {
const _IdentityCard({required this.id});
final Identity id;
@override
Widget build(BuildContext context) {
final lines = <String>[
'Rolle: ${id.roleLabel}',
if (id.email != null) 'E-Mail: ${id.email}',
if (id.isLeitungsteam)
'Leitungsteam-Rechte gelten KC-übergreifend.'
else if (id.memberships.length > 1)
'${id.memberships.length} Zuordnungen',
];
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Angemeldet', style: Theme.of(context).textTheme.labelMedium),
const SizedBox(height: 4),
for (final l in lines) Text(l),
],
),
),
);
}
}
class _NavTile extends StatelessWidget {
const _NavTile({
required this.icon,
required this.title,
required this.subtitle,
required this.onTap,
});
final IconData icon;
final String title;
final String subtitle;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Card(
child: ListTile(
leading: Icon(icon),
title: Text(title),
subtitle: Text(subtitle),
trailing: const Icon(Icons.chevron_right),
onTap: onTap,
),
);
}
}
+206
View File
@@ -0,0 +1,206 @@
import 'package:flutter/material.dart';
import '../api.dart';
import '../main.dart';
class LoginScreen extends StatelessWidget {
const LoginScreen({super.key});
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 3,
child: Scaffold(
appBar: AppBar(
title: const Text('KC-App'),
bottom: const TabBar(
tabs: [
Tab(text: 'Konfi / Gast'),
Tab(text: 'Team-Login'),
Tab(text: 'Einladung'),
],
),
),
body: SafeArea(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Padding(
padding: const EdgeInsets.all(24),
child: TabBarView(
children: const [
_GuestForm(),
_TeamForm(),
_InviteForm(),
],
),
),
),
),
),
),
);
}
}
/// Shared submit-button + error handling for the three little forms.
class _FormShell extends StatefulWidget {
const _FormShell({required this.title, required this.fields, required this.onSubmit});
final String title;
final List<Widget> fields;
final Future<void> Function() onSubmit;
@override
State<_FormShell> createState() => _FormShellState();
}
class _FormShellState extends State<_FormShell> {
bool _busy = false;
String? _error;
Future<void> _run() async {
setState(() {
_busy = true;
_error = null;
});
try {
await widget.onSubmit();
} on ApiException catch (e) {
setState(() => _error = e.message);
} catch (e) {
setState(() => _error = e.toString());
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
return ListView(
shrinkWrap: true,
children: [
Text(widget.title, style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 16),
...widget.fields,
const SizedBox(height: 20),
if (_error != null) ...[
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
const SizedBox(height: 12),
],
FilledButton(
onPressed: _busy ? null : _run,
child: _busy
? const SizedBox(
height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Weiter'),
),
],
);
}
}
TextField _field(TextEditingController c, String label, {bool obscure = false}) => TextField(
controller: c,
obscureText: obscure,
decoration: InputDecoration(labelText: label, border: const OutlineInputBorder()),
);
class _GuestForm extends StatefulWidget {
const _GuestForm();
@override
State<_GuestForm> createState() => _GuestFormState();
}
class _GuestFormState extends State<_GuestForm> {
final _code = TextEditingController();
final _first = TextEditingController();
final _last = TextEditingController();
@override
Widget build(BuildContext context) {
final state = AppScope.of(context);
return _FormShell(
title: 'Mit Einladungscode beitreten',
fields: [
_field(_code, 'Einladungscode'),
const SizedBox(height: 12),
_field(_first, 'Vorname'),
const SizedBox(height: 12),
_field(_last, 'Nachname'),
],
onSubmit: () => state.guestLogin(_code.text.trim(), _first.text.trim(), _last.text.trim()),
);
}
}
class _TeamForm extends StatefulWidget {
const _TeamForm();
@override
State<_TeamForm> createState() => _TeamFormState();
}
class _TeamFormState extends State<_TeamForm> {
final _email = TextEditingController();
final _password = TextEditingController();
@override
Widget build(BuildContext context) {
final state = AppScope.of(context);
return _FormShell(
title: 'Teamer:in-Login',
fields: [
_field(_email, 'E-Mail'),
const SizedBox(height: 12),
_field(_password, 'Passwort', obscure: true),
const SizedBox(height: 8),
const Text(
'Leitungsteam & Gemeinde-Verantwortliche melden sich über die '
'Konfi-Castle-ID (Authentik) an — dieser Client deckt bisher den '
'lokalen Teamer-Login ab.',
style: TextStyle(fontSize: 12),
),
],
onSubmit: () => state.teamLogin(_email.text.trim(), _password.text),
);
}
}
class _InviteForm extends StatefulWidget {
const _InviteForm();
@override
State<_InviteForm> createState() => _InviteFormState();
}
class _InviteFormState extends State<_InviteForm> {
final _token = TextEditingController();
final _first = TextEditingController();
final _last = TextEditingController();
final _email = TextEditingController();
final _password = TextEditingController();
@override
Widget build(BuildContext context) {
final state = AppScope.of(context);
return _FormShell(
title: 'Teamer:in-Einladung einlösen',
fields: [
_field(_token, 'Einladungscode / Token'),
const SizedBox(height: 12),
_field(_first, 'Vorname'),
const SizedBox(height: 12),
_field(_last, 'Nachname'),
const SizedBox(height: 12),
_field(_email, 'E-Mail (bei Gruppen-Link nötig)'),
const SizedBox(height: 12),
_field(_password, 'Passwort wählen (min. 8 Zeichen)', obscure: true),
],
onSubmit: () => state.redeemInvite(
token: _token.text.trim(),
first: _first.text.trim(),
last: _last.text.trim(),
password: _password.text,
email: _email.text.trim(),
),
);
}
}
+223
View File
@@ -0,0 +1,223 @@
import 'package:flutter/material.dart';
import '../api.dart';
import '../main.dart';
class WahlScreen extends StatefulWidget {
const WahlScreen({super.key});
@override
State<WahlScreen> createState() => _WahlScreenState();
}
class _WahlScreenState extends State<WahlScreen> {
Future<GuestOverview>? _future;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_future ??= AppScope.of(context).api.guestWahlOverview();
}
void _reload() {
setState(() {
_future = AppScope.of(context).api.guestWahlOverview();
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Workshop-Wahl')),
body: FutureBuilder<GuestOverview>(
future: _future,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) {
return _ErrorView(message: '${snap.error}', onRetry: _reload);
}
final data = snap.data!;
if (data.wahlen.isEmpty) {
return const Center(child: Text('Aktuell ist keine Wahl geöffnet.'));
}
return ListView(
padding: const EdgeInsets.all(16),
children: [
Text(data.kcName, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
for (final w in data.wahlen)
_WahlCard(wahl: w, onSubmitted: _reload),
],
);
},
),
);
}
}
class _WahlCard extends StatefulWidget {
const _WahlCard({required this.wahl, required this.onSubmitted});
final Wahl wahl;
final VoidCallback onSubmitted;
@override
State<_WahlCard> createState() => _WahlCardState();
}
class _WahlCardState extends State<_WahlCard> {
late final List<String> _picked = [...?widget.wahl.meinePrioritaeten];
bool _busy = false;
String? _error;
bool _done = false;
static const _maxPicks = 3;
void _toggle(String workshopId) {
setState(() {
if (_picked.contains(workshopId)) {
_picked.remove(workshopId);
} else if (_picked.length < _maxPicks) {
_picked.add(workshopId);
}
_done = false;
});
}
Future<void> _submit() async {
setState(() {
_busy = true;
_error = null;
});
try {
await AppScope.of(context).api.submitPrioritaeten(widget.wahl.id, _picked);
setState(() => _done = true);
widget.onSubmitted();
} on ApiException catch (e) {
setState(() => _error = e.message);
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
final w = widget.wahl;
return Card(
margin: const EdgeInsets.only(bottom: 16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(w.name, style: Theme.of(context).textTheme.titleLarge),
Text('${w.datumsSchluessel} · Teil ${w.teil}',
style: Theme.of(context).textTheme.bodySmall),
const SizedBox(height: 4),
Text(
'Tippe deine Wünsche in Reihenfolge an (max. $_maxPicks). '
'Die Zahl zeigt den Rang.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 8),
for (final ws in w.workshops)
_WorkshopRow(
workshop: ws,
rank: _picked.indexOf(ws.id),
enabled: !_busy,
onTap: () => _toggle(ws.id),
),
const SizedBox(height: 12),
if (_error != null) ...[
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
const SizedBox(height: 8),
],
Row(
children: [
FilledButton(
onPressed: (_busy || _picked.isEmpty) ? null : _submit,
child: _busy
? const SizedBox(
height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Wünsche absenden'),
),
const SizedBox(width: 12),
if (_done)
Row(
children: const [
Icon(Icons.check_circle, color: Colors.green, size: 20),
SizedBox(width: 4),
Text('Gespeichert'),
],
),
],
),
],
),
),
);
}
}
class _WorkshopRow extends StatelessWidget {
const _WorkshopRow({
required this.workshop,
required this.rank,
required this.enabled,
required this.onTap,
});
final Workshop workshop;
final int rank;
final bool enabled;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final selected = rank >= 0;
return ListTile(
dense: true,
enabled: enabled,
onTap: onTap,
leading: CircleAvatar(
radius: 14,
backgroundColor: selected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Text(
selected ? '${rank + 1}' : '',
style: TextStyle(
fontSize: 13,
color: selected ? Theme.of(context).colorScheme.onPrimary : null,
),
),
),
title: Text(workshop.name),
subtitle: Text('Kapazität ${workshop.kapazitaet}'),
trailing: Icon(selected ? Icons.check_box : Icons.check_box_outline_blank),
);
}
}
class _ErrorView extends StatelessWidget {
const _ErrorView({required this.message, required this.onRetry});
final String message;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(message, textAlign: TextAlign.center),
const SizedBox(height: 12),
OutlinedButton(onPressed: onRetry, child: const Text('Erneut versuchen')),
],
),
),
);
}
}