feat: client monorepo (Flutter app) + web fallback redesign #1
@@ -15,10 +15,14 @@ import { ChatCaller, ChatService } from './chat.service';
|
||||
/// Raw `ws` gateway (no socket.io rooms available), so channel membership is
|
||||
/// tracked manually per connected socket. Auth happens once at handshake via
|
||||
/// a `?token=` query param since passport guards don't run for WS upgrades.
|
||||
///
|
||||
/// The per-socket caller is stored as a *promise*: the token check is async
|
||||
/// and a client can send `chat:join` before it resolves, so handlers await
|
||||
/// the stored promise instead of assuming it's already populated.
|
||||
@WebSocketGateway({ path: '/chat' })
|
||||
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
private readonly logger = new Logger(ChatGateway.name);
|
||||
private readonly callers = new WeakMap<WebSocket, ChatCaller>();
|
||||
private readonly callers = new Map<WebSocket, Promise<ChatCaller>>();
|
||||
private readonly rooms = new Map<string, Set<WebSocket>>();
|
||||
|
||||
constructor(
|
||||
@@ -26,18 +30,18 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
private readonly chat: ChatService,
|
||||
) {}
|
||||
|
||||
async handleConnection(client: WebSocket, request: IncomingMessage) {
|
||||
handleConnection(client: WebSocket, request: IncomingMessage) {
|
||||
const token = new URL(request.url ?? '', 'http://localhost').searchParams.get('token');
|
||||
if (!token) {
|
||||
client.close(4001, 'Missing token');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.callers.set(client, await this.tokenVerification.verifyEither(token));
|
||||
} catch (err) {
|
||||
const pending = this.tokenVerification.verifyEither(token).catch((err) => {
|
||||
this.logger.warn(`WS auth failed: ${(err as Error).message}`);
|
||||
client.close(4001, 'Unauthorized');
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
this.callers.set(client, pending);
|
||||
}
|
||||
|
||||
handleDisconnect(client: WebSocket) {
|
||||
@@ -52,7 +56,7 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
@ConnectedSocket() client: WebSocket,
|
||||
@MessageBody() data: { channelId: string },
|
||||
) {
|
||||
const caller = this.requireCaller(client);
|
||||
const caller = await this.resolveCaller(client);
|
||||
await this.chat.assertCanRead(data.channelId, caller);
|
||||
this.roomFor(data.channelId).add(client);
|
||||
return { event: 'chat:joined', data: { channelId: data.channelId } };
|
||||
@@ -63,19 +67,24 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
@ConnectedSocket() client: WebSocket,
|
||||
@MessageBody() data: { channelId: string; body: string },
|
||||
) {
|
||||
const caller = this.requireCaller(client);
|
||||
const caller = await this.resolveCaller(client);
|
||||
const message = await this.chat.sendMessage(data.channelId, caller, data.body);
|
||||
this.broadcast(data.channelId, { event: 'chat:message', data: message });
|
||||
return { event: 'chat:sent', data: { id: message.id } };
|
||||
}
|
||||
|
||||
private requireCaller(client: WebSocket): ChatCaller {
|
||||
const caller = this.callers.get(client);
|
||||
if (!caller) {
|
||||
private async resolveCaller(client: WebSocket): Promise<ChatCaller> {
|
||||
const pending = this.callers.get(client);
|
||||
if (!pending) {
|
||||
client.close(4001, 'Unauthorized');
|
||||
throw new Error('Unauthorized WS client');
|
||||
}
|
||||
try {
|
||||
return await pending;
|
||||
} catch {
|
||||
client.close(4001, 'Unauthorized');
|
||||
throw new Error('Unauthorized WS client');
|
||||
}
|
||||
return caller;
|
||||
}
|
||||
|
||||
private roomFor(channelId: string): Set<WebSocket> {
|
||||
|
||||
@@ -145,6 +145,44 @@ class GuestOverview {
|
||||
);
|
||||
}
|
||||
|
||||
enum WahlResultStatus { pending, assigned, unassigned }
|
||||
|
||||
class WahlResult {
|
||||
WahlResult({
|
||||
required this.wahlName,
|
||||
required this.datumsSchluessel,
|
||||
required this.teil,
|
||||
required this.status,
|
||||
required this.workshopName,
|
||||
required this.wunschRang,
|
||||
required this.isForced,
|
||||
});
|
||||
final String wahlName;
|
||||
final String datumsSchluessel;
|
||||
final String teil;
|
||||
final WahlResultStatus status;
|
||||
final String? workshopName;
|
||||
final int? wunschRang;
|
||||
final bool isForced;
|
||||
|
||||
factory WahlResult.fromJson(Map<String, dynamic> j) {
|
||||
final wahl = j['wahl'] as Map<String, dynamic>;
|
||||
return WahlResult(
|
||||
wahlName: wahl['name'] as String,
|
||||
datumsSchluessel: wahl['datumsSchluessel'] as String,
|
||||
teil: wahl['teil'] as String,
|
||||
status: switch (j['status'] as String?) {
|
||||
'ASSIGNED' => WahlResultStatus.assigned,
|
||||
'UNASSIGNED' => WahlResultStatus.unassigned,
|
||||
_ => WahlResultStatus.pending,
|
||||
},
|
||||
workshopName: j['workshopName'] as String?,
|
||||
wunschRang: (j['wunschRang'] as num?)?.toInt(),
|
||||
isForced: j['isForced'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FileEntry {
|
||||
FileEntry({required this.id, required this.filename, required this.visibility});
|
||||
final String id;
|
||||
@@ -262,6 +300,11 @@ class Api {
|
||||
await _post('/wahl/$wahlId/teilnehmer', {'prioritaeten': workshopIds});
|
||||
}
|
||||
|
||||
Future<List<WahlResult>> guestWahlResults() async {
|
||||
final list = await _get('/wahl/guest/results') as List<dynamic>;
|
||||
return list.map((e) => WahlResult.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
// --- files ---
|
||||
Future<List<FileEntry>> files(String kcId) async {
|
||||
final list = await _get('/files/$kcId') as List<dynamic>;
|
||||
@@ -270,6 +313,19 @@ class Api {
|
||||
|
||||
String fileDownloadUrl(String fileId) => '$kApiBase/files/download/$fileId';
|
||||
|
||||
/// WebSocket endpoint for the chat gateway. It lives at `/chat` (outside the
|
||||
/// `/api` prefix) and authenticates via a `?token=` query param.
|
||||
Uri chatWsUri() {
|
||||
final base = Uri.parse(kApiBase);
|
||||
return Uri(
|
||||
scheme: base.scheme == 'https' ? 'wss' : 'ws',
|
||||
host: base.host,
|
||||
port: base.hasPort ? base.port : null,
|
||||
path: '/chat',
|
||||
queryParameters: {'token': token ?? ''},
|
||||
);
|
||||
}
|
||||
|
||||
// --- 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>;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
import 'api.dart';
|
||||
|
||||
/// Thin wrapper over the raw `ws` chat gateway. The NestJS `WsAdapter`
|
||||
/// expects `{"event": ..., "data": ...}` frames in both directions.
|
||||
class ChatSocket {
|
||||
ChatSocket(this._uri);
|
||||
final Uri _uri;
|
||||
|
||||
WebSocketChannel? _channel;
|
||||
final _messages = StreamController<ChatMessage>.broadcast();
|
||||
final _status = StreamController<String>.broadcast();
|
||||
|
||||
/// Incoming `chat:message` frames.
|
||||
Stream<ChatMessage> get messages => _messages.stream;
|
||||
|
||||
/// "connected" / "closed" / "error: ..." for a small status line.
|
||||
Stream<String> get status => _status.stream;
|
||||
|
||||
void connect(String channelId) {
|
||||
_channel = WebSocketChannel.connect(_uri);
|
||||
_channel!.stream.listen(
|
||||
(raw) {
|
||||
_status.add('connected');
|
||||
try {
|
||||
final frame = jsonDecode(raw as String) as Map<String, dynamic>;
|
||||
if (frame['event'] == 'chat:message') {
|
||||
_messages.add(ChatMessage.fromJson(frame['data'] as Map<String, dynamic>));
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore frames we don't model
|
||||
}
|
||||
},
|
||||
onError: (Object e) => _status.add('error: $e'),
|
||||
onDone: () => _status.add('closed'),
|
||||
);
|
||||
_send('chat:join', {'channelId': channelId});
|
||||
}
|
||||
|
||||
void sendMessage(String channelId, String body) {
|
||||
_send('chat:send', {'channelId': channelId, 'body': body});
|
||||
}
|
||||
|
||||
void _send(String event, Map<String, dynamic> data) {
|
||||
_channel?.sink.add(jsonEncode({'event': event, 'data': data}));
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_channel?.sink.close();
|
||||
_messages.close();
|
||||
_status.close();
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api.dart';
|
||||
import '../chat_socket.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.
|
||||
/// Chat: channel list (REST) + a per-channel view that loads history over
|
||||
/// REST and then streams live messages over the `/chat` WebSocket gateway
|
||||
/// (`chat:join` / `chat:send` / `chat:message`).
|
||||
class ChatScreen extends StatefulWidget {
|
||||
const ChatScreen({super.key, required this.kcId});
|
||||
final String kcId;
|
||||
@@ -85,41 +87,130 @@ class _ChannelMessages extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _ChannelMessagesState extends State<_ChannelMessages> {
|
||||
Future<List<ChatMessage>>? _future;
|
||||
final List<ChatMessage> _messages = [];
|
||||
final _composer = TextEditingController();
|
||||
final _scroll = ScrollController();
|
||||
ChatSocket? _socket;
|
||||
bool _loading = true;
|
||||
String? _loadError;
|
||||
String _wsStatus = 'verbinde…';
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_future ??= AppScope.of(context).api.messages(widget.channelId);
|
||||
if (_socket != null) return;
|
||||
final api = AppScope.of(context).api;
|
||||
_load(api);
|
||||
_socket = ChatSocket(api.chatWsUri())
|
||||
..connect(widget.channelId)
|
||||
..messages.listen(_onIncoming)
|
||||
..status.listen((s) => mounted ? setState(() => _wsStatus = s) : null);
|
||||
}
|
||||
|
||||
Future<void> _load(Api api) async {
|
||||
try {
|
||||
final history = await api.messages(widget.channelId);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_messages
|
||||
..clear()
|
||||
..addAll(history);
|
||||
_loading = false;
|
||||
});
|
||||
_jump();
|
||||
} catch (e) {
|
||||
if (mounted) setState(() { _loadError = '$e'; _loading = false; });
|
||||
}
|
||||
}
|
||||
|
||||
void _onIncoming(ChatMessage m) {
|
||||
if (!mounted) return;
|
||||
setState(() => _messages.add(m));
|
||||
_jump();
|
||||
}
|
||||
|
||||
void _jump() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scroll.hasClients) {
|
||||
_scroll.jumpTo(_scroll.position.maxScrollExtent);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _send() {
|
||||
final text = _composer.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
_socket?.sendMessage(widget.channelId, text);
|
||||
_composer.clear();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_socket?.dispose();
|
||||
_composer.dispose();
|
||||
_scroll.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@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(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.title),
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(18),
|
||||
child: Text('WebSocket: $_wsStatus', style: const TextStyle(fontSize: 11)),
|
||||
),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(child: _body(context)),
|
||||
SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text('${snap.error}', textAlign: TextAlign.center),
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _composer,
|
||||
onSubmitted: (_) => _send(),
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Nachricht…',
|
||||
border: OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(icon: const Icon(Icons.send), onPressed: _send),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
final messages = snap.data!;
|
||||
if (messages.isEmpty) {
|
||||
|
||||
Widget _body(BuildContext context) {
|
||||
if (_loading) return const Center(child: CircularProgressIndicator());
|
||||
if (_loadError != null) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(_loadError!, textAlign: TextAlign.center),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_messages.isEmpty) {
|
||||
return const Center(child: Text('Noch keine Nachrichten.'));
|
||||
}
|
||||
return ListView.builder(
|
||||
controller: _scroll,
|
||||
padding: const EdgeInsets.all(12),
|
||||
itemCount: messages.length,
|
||||
itemCount: _messages.length,
|
||||
itemBuilder: (context, i) {
|
||||
final m = messages[i];
|
||||
final m = _messages[i];
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Container(
|
||||
@@ -134,26 +225,12 @@ class _ChannelMessagesState extends State<_ChannelMessages> {
|
||||
children: [
|
||||
Text(m.body),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
m.createdAt,
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ class HomeScreen extends StatelessWidget {
|
||||
_NavTile(
|
||||
icon: Icons.forum,
|
||||
title: 'Chat',
|
||||
subtitle: 'Kanäle & Nachrichten (lesen)',
|
||||
subtitle: 'Kanäle, Verlauf & Live-Nachrichten',
|
||||
onTap: () => _open(context, ChatScreen(kcId: kcId)),
|
||||
),
|
||||
];
|
||||
|
||||
@@ -27,9 +27,16 @@ class _WahlScreenState extends State<WahlScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Workshop-Wahl')),
|
||||
body: FutureBuilder<GuestOverview>(
|
||||
return DefaultTabController(
|
||||
length: 2,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Workshop-Wahl'),
|
||||
bottom: const TabBar(tabs: [Tab(text: 'Wünsche'), Tab(text: 'Ergebnis')]),
|
||||
),
|
||||
body: TabBarView(
|
||||
children: [
|
||||
FutureBuilder<GuestOverview>(
|
||||
future: _future,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState != ConnectionState.done) {
|
||||
@@ -53,6 +60,94 @@ class _WahlScreenState extends State<WahlScreen> {
|
||||
);
|
||||
},
|
||||
),
|
||||
const _ErgebnisTab(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ErgebnisTab extends StatefulWidget {
|
||||
const _ErgebnisTab();
|
||||
@override
|
||||
State<_ErgebnisTab> createState() => _ErgebnisTabState();
|
||||
}
|
||||
|
||||
class _ErgebnisTabState extends State<_ErgebnisTab> {
|
||||
Future<List<WahlResult>>? _future;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_future ??= AppScope.of(context).api.guestWahlResults();
|
||||
}
|
||||
|
||||
void _reload() {
|
||||
setState(() => _future = AppScope.of(context).api.guestWahlResults());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<List<WahlResult>>(
|
||||
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 results = snap.data!;
|
||||
if (results.isEmpty) {
|
||||
return const Center(child: Text('Noch keine Teilnahme an einer Wahl.'));
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async => _reload(),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [for (final r in results) _ResultCard(result: r)],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ResultCard extends StatelessWidget {
|
||||
const _ResultCard({required this.result});
|
||||
final WahlResult result;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (label, color, detail) = switch (result.status) {
|
||||
WahlResultStatus.assigned => (
|
||||
result.workshopName ?? 'Zugeteilt',
|
||||
Colors.green,
|
||||
result.isForced
|
||||
? 'Fest zugeteilt (Leitungsteam)'
|
||||
: 'Wunsch ${result.wunschRang ?? '?'}',
|
||||
),
|
||||
WahlResultStatus.unassigned => (
|
||||
'Kein Platz frei',
|
||||
Theme.of(context).colorScheme.error,
|
||||
'Bitte beim Leitungsteam melden.',
|
||||
),
|
||||
WahlResultStatus.pending => (
|
||||
'Noch nicht zugeteilt',
|
||||
Theme.of(context).colorScheme.outline,
|
||||
'Die Zuteilung läuft noch.',
|
||||
),
|
||||
};
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.emoji_events, color: color),
|
||||
title: Text('${result.wahlName} · ${result.datumsSchluessel} Teil ${result.teil}',
|
||||
style: Theme.of(context).textTheme.bodySmall),
|
||||
subtitle: Text(label, style: Theme.of(context).textTheme.titleMedium),
|
||||
trailing: Text(detail, textAlign: TextAlign.end),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -349,6 +357,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
web_socket:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web_socket
|
||||
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
web_socket_channel:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: web_socket_channel
|
||||
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -11,6 +11,7 @@ dependencies:
|
||||
sdk: flutter
|
||||
http: ^1.2.2
|
||||
shared_preferences: ^2.3.2
|
||||
web_socket_channel: ^3.0.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
Reference in New Issue
Block a user