Files
KC-APP/client/app/lib/screens/home_screen.dart
T
linusandClaude Sonnet 5 e55faeaa95 feat: Wahl result view + live WebSocket chat in the Flutter client
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>
2026-09-10 09:02:40 +02:00

140 lines
3.7 KiB
Dart

import 'package:flutter/material.dart';
import '../api.dart';
import '../main.dart';
import 'chat_screen.dart';
import 'files_screen.dart';
import 'wahl_screen.dart';
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
final state = AppScope.of(context);
final id = state.identity!;
final kcId = id.kcId;
final tiles = <Widget>[
if (id.kind == SessionKind.guest)
_NavTile(
icon: Icons.how_to_vote,
title: 'Workshop-Wahl',
subtitle: 'Deine Wünsche abgeben',
onTap: () => _open(context, const WahlScreen()),
),
if (kcId != null)
_NavTile(
icon: Icons.folder_shared,
title: 'Dateien',
subtitle: 'Freigegebene Dateien ansehen',
onTap: () => _open(context, FilesScreen(kcId: kcId)),
),
if (kcId != null)
_NavTile(
icon: Icons.forum,
title: 'Chat',
subtitle: 'Kanäle, Verlauf & Live-Nachrichten',
onTap: () => _open(context, ChatScreen(kcId: kcId)),
),
];
return Scaffold(
appBar: AppBar(
title: const Text('KC-App'),
actions: [
IconButton(
tooltip: 'Abmelden',
onPressed: state.logout,
icon: const Icon(Icons.logout),
),
],
),
body: SafeArea(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: ListView(
padding: const EdgeInsets.all(20),
children: [
_IdentityCard(id: id),
const SizedBox(height: 16),
...tiles,
if (tiles.isEmpty)
const Padding(
padding: EdgeInsets.only(top: 24),
child: Text(
'Für diesen Account gibt es hier noch keine Ansichten. '
'Sobald dir eine Gemeinde/ein KC zugeordnet ist, erscheinen '
'Dateien und Chat.',
),
),
],
),
),
),
),
);
}
void _open(BuildContext context, Widget screen) {
Navigator.of(context).push(MaterialPageRoute(builder: (_) => screen));
}
}
class _IdentityCard extends StatelessWidget {
const _IdentityCard({required this.id});
final Identity id;
@override
Widget build(BuildContext context) {
final lines = <String>[
'Rolle: ${id.roleLabel}',
if (id.email != null) 'E-Mail: ${id.email}',
if (id.isLeitungsteam)
'Leitungsteam-Rechte gelten KC-übergreifend.'
else if (id.memberships.length > 1)
'${id.memberships.length} Zuordnungen',
];
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Angemeldet', style: Theme.of(context).textTheme.labelMedium),
const SizedBox(height: 4),
for (final l in lines) Text(l),
],
),
),
);
}
}
class _NavTile extends StatelessWidget {
const _NavTile({
required this.icon,
required this.title,
required this.subtitle,
required this.onTap,
});
final IconData icon;
final String title;
final String subtitle;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Card(
child: ListTile(
leading: Icon(icon),
title: Text(title),
subtitle: Text(subtitle),
trailing: const Icon(Icons.chevron_right),
onTap: onTap,
),
);
}
}