feat(client): Flutter app (web target) — Phase 7 start
Single Flutter codebase under client/app/ with web enabled (mobile/desktop can be added later; lib/ is platform-agnostic). Talks to the NestJS backend via a thin REST wrapper; API_BASE is a --dart-define (defaults to the local backend). Screens: - Login: Konfi/guest (invite code), local Teamer password login, Teamer invite redemption. Token persisted in shared_preferences, restored on start; GET /auth/me drives a role-aware home. - Workshop-Wahl (guests): loads /wahl/guest/overview, ordered pick of up to 3 workshops, submits to /wahl/:id/teilnehmer. - Dateien: /files/:kcId list. - Chat: channel + message list (read-only; WS send is a follow-up). State: AppState (ChangeNotifier) exposed via an InheritedNotifier (AppScope) — no third-party state package. flutter analyze clean, flutter build web --release passes, one widget smoke test. Also: interim client/web/ HTML placeholder stays as-is (per plan it is superseded by this Flutter web build). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api.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.
|
||||
class ChatScreen extends StatefulWidget {
|
||||
const ChatScreen({super.key, required this.kcId});
|
||||
final String kcId;
|
||||
|
||||
@override
|
||||
State<ChatScreen> createState() => _ChatScreenState();
|
||||
}
|
||||
|
||||
class _ChatScreenState extends State<ChatScreen> {
|
||||
Future<List<ChatChannel>>? _future;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_future ??= AppScope.of(context).api.channels(widget.kcId);
|
||||
}
|
||||
|
||||
static const _typeLabels = {
|
||||
'GEMEINDE_GRUPPE': 'Gemeinde-Gruppe',
|
||||
'DIREKT': 'Direktnachricht',
|
||||
'LT_UEBERGREIFEND': 'Leitungsteam',
|
||||
'BROADCAST': 'Ankündigungen',
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Chat')),
|
||||
body: FutureBuilder<List<ChatChannel>>(
|
||||
future: _future,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState != ConnectionState.done) {
|
||||
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 channels = snap.data!;
|
||||
if (channels.isEmpty) {
|
||||
return const Center(child: Text('Keine Kanäle sichtbar.'));
|
||||
}
|
||||
return ListView(
|
||||
children: [
|
||||
for (final c in channels)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.tag),
|
||||
title: Text(_typeLabels[c.type] ?? c.type),
|
||||
subtitle: Text(c.id),
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => _ChannelMessages(
|
||||
channelId: c.id,
|
||||
title: _typeLabels[c.type] ?? c.type,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChannelMessages extends StatefulWidget {
|
||||
const _ChannelMessages({required this.channelId, required this.title});
|
||||
final String channelId;
|
||||
final String title;
|
||||
|
||||
@override
|
||||
State<_ChannelMessages> createState() => _ChannelMessagesState();
|
||||
}
|
||||
|
||||
class _ChannelMessagesState extends State<_ChannelMessages> {
|
||||
Future<List<ChatMessage>>? _future;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_future ??= AppScope.of(context).api.messages(widget.channelId);
|
||||
}
|
||||
|
||||
@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(
|
||||
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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api.dart';
|
||||
import '../main.dart';
|
||||
|
||||
class FilesScreen extends StatefulWidget {
|
||||
const FilesScreen({super.key, required this.kcId});
|
||||
final String kcId;
|
||||
|
||||
@override
|
||||
State<FilesScreen> createState() => _FilesScreenState();
|
||||
}
|
||||
|
||||
class _FilesScreenState extends State<FilesScreen> {
|
||||
Future<List<FileEntry>>? _future;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_future ??= AppScope.of(context).api.files(widget.kcId);
|
||||
}
|
||||
|
||||
static const _visibilityLabels = {
|
||||
'ALLE': 'Alle',
|
||||
'ALLE_AUSSER_KONFIS': 'Team (ohne Konfis)',
|
||||
'NUR_LT': 'Nur Leitungsteam',
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Dateien')),
|
||||
body: FutureBuilder<List<FileEntry>>(
|
||||
future: _future,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState != ConnectionState.done) {
|
||||
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 files = snap.data!;
|
||||
if (files.isEmpty) {
|
||||
return const Center(child: Text('Keine Dateien freigegeben.'));
|
||||
}
|
||||
return ListView.separated(
|
||||
itemCount: files.length,
|
||||
separatorBuilder: (context, index) => const Divider(height: 1),
|
||||
itemBuilder: (context, i) {
|
||||
final f = files[i];
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.insert_drive_file_outlined),
|
||||
title: Text(f.filename),
|
||||
subtitle: Text(_visibilityLabels[f.visibility] ?? f.visibility),
|
||||
trailing: const Icon(Icons.download),
|
||||
onTap: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Download-URL: ${AppScope.of(context).api.fileDownloadUrl(f.id)}',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
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 & Nachrichten (lesen)',
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api.dart';
|
||||
import '../main.dart';
|
||||
|
||||
class LoginScreen extends StatelessWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DefaultTabController(
|
||||
length: 3,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('KC-App'),
|
||||
bottom: const TabBar(
|
||||
tabs: [
|
||||
Tab(text: 'Konfi / Gast'),
|
||||
Tab(text: 'Team-Login'),
|
||||
Tab(text: 'Einladung'),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: TabBarView(
|
||||
children: const [
|
||||
_GuestForm(),
|
||||
_TeamForm(),
|
||||
_InviteForm(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared submit-button + error handling for the three little forms.
|
||||
class _FormShell extends StatefulWidget {
|
||||
const _FormShell({required this.title, required this.fields, required this.onSubmit});
|
||||
final String title;
|
||||
final List<Widget> fields;
|
||||
final Future<void> Function() onSubmit;
|
||||
|
||||
@override
|
||||
State<_FormShell> createState() => _FormShellState();
|
||||
}
|
||||
|
||||
class _FormShellState extends State<_FormShell> {
|
||||
bool _busy = false;
|
||||
String? _error;
|
||||
|
||||
Future<void> _run() async {
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
await widget.onSubmit();
|
||||
} on ApiException catch (e) {
|
||||
setState(() => _error = e.message);
|
||||
} catch (e) {
|
||||
setState(() => _error = e.toString());
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
Text(widget.title, style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 16),
|
||||
...widget.fields,
|
||||
const SizedBox(height: 20),
|
||||
if (_error != null) ...[
|
||||
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
FilledButton(
|
||||
onPressed: _busy ? null : _run,
|
||||
child: _busy
|
||||
? const SizedBox(
|
||||
height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Text('Weiter'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TextField _field(TextEditingController c, String label, {bool obscure = false}) => TextField(
|
||||
controller: c,
|
||||
obscureText: obscure,
|
||||
decoration: InputDecoration(labelText: label, border: const OutlineInputBorder()),
|
||||
);
|
||||
|
||||
class _GuestForm extends StatefulWidget {
|
||||
const _GuestForm();
|
||||
@override
|
||||
State<_GuestForm> createState() => _GuestFormState();
|
||||
}
|
||||
|
||||
class _GuestFormState extends State<_GuestForm> {
|
||||
final _code = TextEditingController();
|
||||
final _first = TextEditingController();
|
||||
final _last = TextEditingController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = AppScope.of(context);
|
||||
return _FormShell(
|
||||
title: 'Mit Einladungscode beitreten',
|
||||
fields: [
|
||||
_field(_code, 'Einladungscode'),
|
||||
const SizedBox(height: 12),
|
||||
_field(_first, 'Vorname'),
|
||||
const SizedBox(height: 12),
|
||||
_field(_last, 'Nachname'),
|
||||
],
|
||||
onSubmit: () => state.guestLogin(_code.text.trim(), _first.text.trim(), _last.text.trim()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TeamForm extends StatefulWidget {
|
||||
const _TeamForm();
|
||||
@override
|
||||
State<_TeamForm> createState() => _TeamFormState();
|
||||
}
|
||||
|
||||
class _TeamFormState extends State<_TeamForm> {
|
||||
final _email = TextEditingController();
|
||||
final _password = TextEditingController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = AppScope.of(context);
|
||||
return _FormShell(
|
||||
title: 'Teamer:in-Login',
|
||||
fields: [
|
||||
_field(_email, 'E-Mail'),
|
||||
const SizedBox(height: 12),
|
||||
_field(_password, 'Passwort', obscure: true),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Leitungsteam & Gemeinde-Verantwortliche melden sich über die '
|
||||
'Konfi-Castle-ID (Authentik) an — dieser Client deckt bisher den '
|
||||
'lokalen Teamer-Login ab.',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
],
|
||||
onSubmit: () => state.teamLogin(_email.text.trim(), _password.text),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InviteForm extends StatefulWidget {
|
||||
const _InviteForm();
|
||||
@override
|
||||
State<_InviteForm> createState() => _InviteFormState();
|
||||
}
|
||||
|
||||
class _InviteFormState extends State<_InviteForm> {
|
||||
final _token = TextEditingController();
|
||||
final _first = TextEditingController();
|
||||
final _last = TextEditingController();
|
||||
final _email = TextEditingController();
|
||||
final _password = TextEditingController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = AppScope.of(context);
|
||||
return _FormShell(
|
||||
title: 'Teamer:in-Einladung einlösen',
|
||||
fields: [
|
||||
_field(_token, 'Einladungscode / Token'),
|
||||
const SizedBox(height: 12),
|
||||
_field(_first, 'Vorname'),
|
||||
const SizedBox(height: 12),
|
||||
_field(_last, 'Nachname'),
|
||||
const SizedBox(height: 12),
|
||||
_field(_email, 'E-Mail (bei Gruppen-Link nötig)'),
|
||||
const SizedBox(height: 12),
|
||||
_field(_password, 'Passwort wählen (min. 8 Zeichen)', obscure: true),
|
||||
],
|
||||
onSubmit: () => state.redeemInvite(
|
||||
token: _token.text.trim(),
|
||||
first: _first.text.trim(),
|
||||
last: _last.text.trim(),
|
||||
password: _password.text,
|
||||
email: _email.text.trim(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
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 Scaffold(
|
||||
appBar: AppBar(title: const Text('Workshop-Wahl')),
|
||||
body: 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),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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')),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user