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,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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user