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
|
/// Raw `ws` gateway (no socket.io rooms available), so channel membership is
|
||||||
/// tracked manually per connected socket. Auth happens once at handshake via
|
/// tracked manually per connected socket. Auth happens once at handshake via
|
||||||
/// a `?token=` query param since passport guards don't run for WS upgrades.
|
/// 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' })
|
@WebSocketGateway({ path: '/chat' })
|
||||||
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||||
private readonly logger = new Logger(ChatGateway.name);
|
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>>();
|
private readonly rooms = new Map<string, Set<WebSocket>>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -26,18 +30,18 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|||||||
private readonly chat: ChatService,
|
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');
|
const token = new URL(request.url ?? '', 'http://localhost').searchParams.get('token');
|
||||||
if (!token) {
|
if (!token) {
|
||||||
client.close(4001, 'Missing token');
|
client.close(4001, 'Missing token');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
const pending = this.tokenVerification.verifyEither(token).catch((err) => {
|
||||||
this.callers.set(client, await this.tokenVerification.verifyEither(token));
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.warn(`WS auth failed: ${(err as Error).message}`);
|
this.logger.warn(`WS auth failed: ${(err as Error).message}`);
|
||||||
client.close(4001, 'Unauthorized');
|
client.close(4001, 'Unauthorized');
|
||||||
}
|
throw err;
|
||||||
|
});
|
||||||
|
this.callers.set(client, pending);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleDisconnect(client: WebSocket) {
|
handleDisconnect(client: WebSocket) {
|
||||||
@@ -52,7 +56,7 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|||||||
@ConnectedSocket() client: WebSocket,
|
@ConnectedSocket() client: WebSocket,
|
||||||
@MessageBody() data: { channelId: string },
|
@MessageBody() data: { channelId: string },
|
||||||
) {
|
) {
|
||||||
const caller = this.requireCaller(client);
|
const caller = await this.resolveCaller(client);
|
||||||
await this.chat.assertCanRead(data.channelId, caller);
|
await this.chat.assertCanRead(data.channelId, caller);
|
||||||
this.roomFor(data.channelId).add(client);
|
this.roomFor(data.channelId).add(client);
|
||||||
return { event: 'chat:joined', data: { channelId: data.channelId } };
|
return { event: 'chat:joined', data: { channelId: data.channelId } };
|
||||||
@@ -63,19 +67,24 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|||||||
@ConnectedSocket() client: WebSocket,
|
@ConnectedSocket() client: WebSocket,
|
||||||
@MessageBody() data: { channelId: string; body: string },
|
@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);
|
const message = await this.chat.sendMessage(data.channelId, caller, data.body);
|
||||||
this.broadcast(data.channelId, { event: 'chat:message', data: message });
|
this.broadcast(data.channelId, { event: 'chat:message', data: message });
|
||||||
return { event: 'chat:sent', data: { id: message.id } };
|
return { event: 'chat:sent', data: { id: message.id } };
|
||||||
}
|
}
|
||||||
|
|
||||||
private requireCaller(client: WebSocket): ChatCaller {
|
private async resolveCaller(client: WebSocket): Promise<ChatCaller> {
|
||||||
const caller = this.callers.get(client);
|
const pending = this.callers.get(client);
|
||||||
if (!caller) {
|
if (!pending) {
|
||||||
|
client.close(4001, 'Unauthorized');
|
||||||
|
throw new Error('Unauthorized WS client');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await pending;
|
||||||
|
} catch {
|
||||||
client.close(4001, 'Unauthorized');
|
client.close(4001, 'Unauthorized');
|
||||||
throw new Error('Unauthorized WS client');
|
throw new Error('Unauthorized WS client');
|
||||||
}
|
}
|
||||||
return caller;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private roomFor(channelId: string): Set<WebSocket> {
|
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 {
|
class FileEntry {
|
||||||
FileEntry({required this.id, required this.filename, required this.visibility});
|
FileEntry({required this.id, required this.filename, required this.visibility});
|
||||||
final String id;
|
final String id;
|
||||||
@@ -262,6 +300,11 @@ class Api {
|
|||||||
await _post('/wahl/$wahlId/teilnehmer', {'prioritaeten': workshopIds});
|
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 ---
|
// --- files ---
|
||||||
Future<List<FileEntry>> files(String kcId) async {
|
Future<List<FileEntry>> files(String kcId) async {
|
||||||
final list = await _get('/files/$kcId') as List<dynamic>;
|
final list = await _get('/files/$kcId') as List<dynamic>;
|
||||||
@@ -270,6 +313,19 @@ class Api {
|
|||||||
|
|
||||||
String fileDownloadUrl(String fileId) => '$kApiBase/files/download/$fileId';
|
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) ---
|
// --- chat (read-only for now; sending is a WebSocket-only path) ---
|
||||||
Future<List<ChatChannel>> channels(String kcId) async {
|
Future<List<ChatChannel>> channels(String kcId) async {
|
||||||
final list = await _get('/chat/$kcId/channels') as List<dynamic>;
|
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 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../api.dart';
|
import '../api.dart';
|
||||||
|
import '../chat_socket.dart';
|
||||||
import '../main.dart';
|
import '../main.dart';
|
||||||
|
|
||||||
/// Read-only chat view. Sending a message is a WebSocket-only path on the
|
/// Chat: channel list (REST) + a per-channel view that loads history over
|
||||||
/// backend (`chat:send`); wiring that up is a follow-up.
|
/// REST and then streams live messages over the `/chat` WebSocket gateway
|
||||||
|
/// (`chat:join` / `chat:send` / `chat:message`).
|
||||||
class ChatScreen extends StatefulWidget {
|
class ChatScreen extends StatefulWidget {
|
||||||
const ChatScreen({super.key, required this.kcId});
|
const ChatScreen({super.key, required this.kcId});
|
||||||
final String kcId;
|
final String kcId;
|
||||||
@@ -85,75 +87,150 @@ class _ChannelMessages extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ChannelMessagesState extends State<_ChannelMessages> {
|
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
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
super.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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text(widget.title)),
|
appBar: AppBar(
|
||||||
body: FutureBuilder<List<ChatMessage>>(
|
title: Text(widget.title),
|
||||||
future: _future,
|
bottom: PreferredSize(
|
||||||
builder: (context, snap) {
|
preferredSize: const Size.fromHeight(18),
|
||||||
if (snap.connectionState != ConnectionState.done) {
|
child: Text('WebSocket: $_wsStatus', style: const TextStyle(fontSize: 11)),
|
||||||
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),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
Expanded(child: _body(context)),
|
||||||
|
SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: Padding(
|
||||||
|
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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ class HomeScreen extends StatelessWidget {
|
|||||||
_NavTile(
|
_NavTile(
|
||||||
icon: Icons.forum,
|
icon: Icons.forum,
|
||||||
title: 'Chat',
|
title: 'Chat',
|
||||||
subtitle: 'Kanäle & Nachrichten (lesen)',
|
subtitle: 'Kanäle, Verlauf & Live-Nachrichten',
|
||||||
onTap: () => _open(context, ChatScreen(kcId: kcId)),
|
onTap: () => _open(context, ChatScreen(kcId: kcId)),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -27,31 +27,126 @@ class _WahlScreenState extends State<WahlScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return DefaultTabController(
|
||||||
appBar: AppBar(title: const Text('Workshop-Wahl')),
|
length: 2,
|
||||||
body: FutureBuilder<GuestOverview>(
|
child: Scaffold(
|
||||||
future: _future,
|
appBar: AppBar(
|
||||||
builder: (context, snap) {
|
title: const Text('Workshop-Wahl'),
|
||||||
if (snap.connectionState != ConnectionState.done) {
|
bottom: const TabBar(tabs: [Tab(text: 'Wünsche'), Tab(text: 'Ergebnis')]),
|
||||||
return const Center(child: CircularProgressIndicator());
|
),
|
||||||
}
|
body: TabBarView(
|
||||||
if (snap.hasError) {
|
children: [
|
||||||
return _ErrorView(message: '${snap.error}', onRetry: _reload);
|
FutureBuilder<GuestOverview>(
|
||||||
}
|
future: _future,
|
||||||
final data = snap.data!;
|
builder: (context, snap) {
|
||||||
if (data.wahlen.isEmpty) {
|
if (snap.connectionState != ConnectionState.done) {
|
||||||
return const Center(child: Text('Aktuell ist keine Wahl geöffnet.'));
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
return ListView(
|
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),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
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),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [for (final r in results) _ResultCard(result: r)],
|
||||||
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 _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"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.19.1"
|
version: "1.19.1"
|
||||||
|
crypto:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: crypto
|
||||||
|
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.7"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -349,6 +357,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.1"
|
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:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ dependencies:
|
|||||||
sdk: flutter
|
sdk: flutter
|
||||||
http: ^1.2.2
|
http: ^1.2.2
|
||||||
shared_preferences: ^2.3.2
|
shared_preferences: ^2.3.2
|
||||||
|
web_socket_channel: ^3.0.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Reference in New Issue
Block a user