Files
linusandClaude Sonnet 5 8af927c0f2 feat(backend): Wahl-Phasen, Verantwortliche-Invites, Auth fixes
- New VerantwortlicheInvite model: LT-issued invites so a person can
  register as Gemeinde Verantwortliche(r) for a specific Gemeinde,
  skipping the self-registration approval step.
- Wahl/Workshop/Teilnehmer gain phase support (phasenAnzahl,
  beschreibung), mirroring the WP plugin's multi-phase elections.
  Teilnehmer unique constraint now scoped per phase.
- Auth: team login + guest auth adjustments, spec coverage.
- sync.service.ts: register VerantwortlicheInvite as a synced model.
- wahl.service.ts: submitTeilnehmer updated for the new phase-scoped
  unique key.
- client: login/home screen rework, new theme.dart, FCM web tweaks.
- .gitignore: ignore .DS_Store.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 18:00:18 +02:00

208 lines
6.1 KiB
Dart

import 'package:flutter/material.dart';
import '../api.dart';
import '../main.dart';
import '../theme.dart';
import 'admin_screen.dart';
import 'chat_screen.dart';
import 'files_screen.dart';
import 'verantwortliche_register_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 needsVerantwRegistration = id.kind == SessionKind.user &&
!id.isLeitungsteam &&
id.memberships.isEmpty;
final tiles = <Widget>[
if (id.isLeitungsteam)
_NavTile(
icon: Icons.admin_panel_settings,
title: 'Verwaltung',
subtitle: 'KCs, Gemeinden, Onboarding-Freigaben',
onTap: () => _open(context, const AdminScreen()),
),
if (needsVerantwRegistration)
_NavTile(
icon: Icons.how_to_reg,
title: 'Als Verantwortliche/r registrieren',
subtitle: 'KC-Code eingeben, Gemeinde wählen, Freigabe abwarten',
onTap: () => _open(context, const VerantwortlicheRegisterScreen()),
),
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>[
if (id.email != null) 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(20),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [KcColors.blue, KcColors.teal],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
shape: BoxShape.circle,
),
child: const Icon(Icons.person, color: Colors.white),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(id.roleLabel, style: Theme.of(context).textTheme.titleMedium),
for (final l in lines) ...[
const SizedBox(height: 2),
Text(l, style: Theme.of(context).textTheme.bodySmall),
],
],
),
),
],
),
),
);
}
}
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(
margin: const EdgeInsets.only(bottom: 12),
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: KcColors.blue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: KcColors.blue),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 2),
Text(subtitle, style: Theme.of(context).textTheme.bodySmall),
],
),
),
Icon(Icons.chevron_right, color: KcColors.slate.withValues(alpha: 0.6)),
],
),
),
),
);
}
}