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),
),
),
);
}
}