Backend: - fix(chat): ChatGateway stored the per-socket caller only after the async token check resolved, so a client that sent chat:join immediately on open raced ahead and got 4001. The caller is now stored as a promise that the message handlers await. Verified with a two-client send/receive E2E test against local Postgres. Client (client/app/): - Wahl screen gains a "Ergebnis" tab backed by GET /wahl/guest/results (PENDING / ASSIGNED with workshop + wish rank / UNASSIGNED). - Chat channel view loads history over REST, then connects the /chat WebSocket (chat_socket.dart): live chat:message stream + a compose bar that sends chat:send. Shows the socket status. - web_socket_channel dependency added. flutter analyze/test/build web all green; backend npm test 56. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
319 lines
9.3 KiB
Dart
319 lines
9.3 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import '../api.dart';
|
|
import '../main.dart';
|
|
|
|
class WahlScreen extends StatefulWidget {
|
|
const WahlScreen({super.key});
|
|
|
|
@override
|
|
State<WahlScreen> createState() => _WahlScreenState();
|
|
}
|
|
|
|
class _WahlScreenState extends State<WahlScreen> {
|
|
Future<GuestOverview>? _future;
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
_future ??= AppScope.of(context).api.guestWahlOverview();
|
|
}
|
|
|
|
void _reload() {
|
|
setState(() {
|
|
_future = AppScope.of(context).api.guestWahlOverview();
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
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) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
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),
|
|
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),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _WahlCard extends StatefulWidget {
|
|
const _WahlCard({required this.wahl, required this.onSubmitted});
|
|
final Wahl wahl;
|
|
final VoidCallback onSubmitted;
|
|
|
|
@override
|
|
State<_WahlCard> createState() => _WahlCardState();
|
|
}
|
|
|
|
class _WahlCardState extends State<_WahlCard> {
|
|
late final List<String> _picked = [...?widget.wahl.meinePrioritaeten];
|
|
bool _busy = false;
|
|
String? _error;
|
|
bool _done = false;
|
|
|
|
static const _maxPicks = 3;
|
|
|
|
void _toggle(String workshopId) {
|
|
setState(() {
|
|
if (_picked.contains(workshopId)) {
|
|
_picked.remove(workshopId);
|
|
} else if (_picked.length < _maxPicks) {
|
|
_picked.add(workshopId);
|
|
}
|
|
_done = false;
|
|
});
|
|
}
|
|
|
|
Future<void> _submit() async {
|
|
setState(() {
|
|
_busy = true;
|
|
_error = null;
|
|
});
|
|
try {
|
|
await AppScope.of(context).api.submitPrioritaeten(widget.wahl.id, _picked);
|
|
setState(() => _done = true);
|
|
widget.onSubmitted();
|
|
} on ApiException catch (e) {
|
|
setState(() => _error = e.message);
|
|
} finally {
|
|
if (mounted) setState(() => _busy = false);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final w = widget.wahl;
|
|
return Card(
|
|
margin: const EdgeInsets.only(bottom: 16),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(w.name, style: Theme.of(context).textTheme.titleLarge),
|
|
Text('${w.datumsSchluessel} · Teil ${w.teil}',
|
|
style: Theme.of(context).textTheme.bodySmall),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'Tippe deine Wünsche in Reihenfolge an (max. $_maxPicks). '
|
|
'Die Zahl zeigt den Rang.',
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
const SizedBox(height: 8),
|
|
for (final ws in w.workshops)
|
|
_WorkshopRow(
|
|
workshop: ws,
|
|
rank: _picked.indexOf(ws.id),
|
|
enabled: !_busy,
|
|
onTap: () => _toggle(ws.id),
|
|
),
|
|
const SizedBox(height: 12),
|
|
if (_error != null) ...[
|
|
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
|
const SizedBox(height: 8),
|
|
],
|
|
Row(
|
|
children: [
|
|
FilledButton(
|
|
onPressed: (_busy || _picked.isEmpty) ? null : _submit,
|
|
child: _busy
|
|
? const SizedBox(
|
|
height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2))
|
|
: const Text('Wünsche absenden'),
|
|
),
|
|
const SizedBox(width: 12),
|
|
if (_done)
|
|
Row(
|
|
children: const [
|
|
Icon(Icons.check_circle, color: Colors.green, size: 20),
|
|
SizedBox(width: 4),
|
|
Text('Gespeichert'),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _WorkshopRow extends StatelessWidget {
|
|
const _WorkshopRow({
|
|
required this.workshop,
|
|
required this.rank,
|
|
required this.enabled,
|
|
required this.onTap,
|
|
});
|
|
final Workshop workshop;
|
|
final int rank;
|
|
final bool enabled;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final selected = rank >= 0;
|
|
return ListTile(
|
|
dense: true,
|
|
enabled: enabled,
|
|
onTap: onTap,
|
|
leading: CircleAvatar(
|
|
radius: 14,
|
|
backgroundColor: selected
|
|
? Theme.of(context).colorScheme.primary
|
|
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
|
child: Text(
|
|
selected ? '${rank + 1}' : '',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
color: selected ? Theme.of(context).colorScheme.onPrimary : null,
|
|
),
|
|
),
|
|
),
|
|
title: Text(workshop.name),
|
|
subtitle: Text('Kapazität ${workshop.kapazitaet}'),
|
|
trailing: Icon(selected ? Icons.check_box : Icons.check_box_outline_blank),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ErrorView extends StatelessWidget {
|
|
const _ErrorView({required this.message, required this.onRetry});
|
|
final String message;
|
|
final VoidCallback onRetry;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(message, textAlign: TextAlign.center),
|
|
const SizedBox(height: 12),
|
|
OutlinedButton(onPressed: onRetry, child: const Text('Erneut versuchen')),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|