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>
160 lines
4.9 KiB
Dart
160 lines
4.9 KiB
Dart
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),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|