- code import fix
@@ -0,0 +1,244 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../data/club_workflows/club_workflows_repository.dart';
|
||||
|
||||
enum ClubWorkflow { training, members, orders, teams, tournaments }
|
||||
|
||||
/// Phase 4: focused desktop write workflows. The forms deliberately expose the
|
||||
/// safe, common fields while leaving specialised administration in the web app.
|
||||
class ClubWorkflowsPage extends StatefulWidget {
|
||||
const ClubWorkflowsPage({
|
||||
super.key,
|
||||
required this.workflow,
|
||||
required this.clubId,
|
||||
required this.api,
|
||||
});
|
||||
|
||||
final ClubWorkflow workflow;
|
||||
final int clubId;
|
||||
final ApiClient api;
|
||||
|
||||
@override
|
||||
State<ClubWorkflowsPage> createState() => _ClubWorkflowsPageState();
|
||||
}
|
||||
|
||||
class _ClubWorkflowsPageState extends State<ClubWorkflowsPage> {
|
||||
late ClubWorkflowsRepository _repository;
|
||||
late Future<List<Map<String, dynamic>>> _items;
|
||||
bool _saving = false;
|
||||
String? _message;
|
||||
bool _isError = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_repository = ClubWorkflowsRepository(widget.api);
|
||||
_items = _load();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant ClubWorkflowsPage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.workflow != widget.workflow || oldWidget.clubId != widget.clubId) {
|
||||
_repository = ClubWorkflowsRepository(widget.api);
|
||||
_items = _load();
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> _load() async {
|
||||
Object? response = switch (widget.workflow) {
|
||||
ClubWorkflow.training => await _repository.training(widget.clubId),
|
||||
ClubWorkflow.members => await _repository.members(widget.clubId),
|
||||
ClubWorkflow.orders => await _repository.members(widget.clubId),
|
||||
ClubWorkflow.teams => await _repository.teams(widget.clubId),
|
||||
ClubWorkflow.tournaments => await _repository.tournaments(widget.clubId),
|
||||
};
|
||||
return _asRecords(response);
|
||||
}
|
||||
|
||||
void _refresh() => setState(() => _items = _load());
|
||||
|
||||
Future<void> _save(Future<Object?> Function() request, String success) async {
|
||||
setState(() {
|
||||
_saving = true;
|
||||
_message = null;
|
||||
});
|
||||
try {
|
||||
await request();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_message = success;
|
||||
_isError = false;
|
||||
});
|
||||
_refresh();
|
||||
} catch (error) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_message = error.toString().replaceFirst('ApiException: ', '');
|
||||
_isError = true;
|
||||
});
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_Editor(
|
||||
workflow: widget.workflow,
|
||||
items: _items,
|
||||
saving: _saving,
|
||||
onSave: _save,
|
||||
repository: _repository,
|
||||
clubId: widget.clubId,
|
||||
onSaved: _refresh,
|
||||
),
|
||||
if (_message != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
_Notice(message: _message!, error: _isError),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
Text('Vorhandene Einträge', style: Theme.of(context).textTheme.titleMedium),
|
||||
const Spacer(),
|
||||
TextButton.icon(onPressed: _saving ? null : _refresh, icon: const Icon(Icons.refresh), label: const Text('Aktualisieren')),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FutureBuilder<List<Map<String, dynamic>>>(
|
||||
future: _items,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState != ConnectionState.done) return const Padding(padding: EdgeInsets.all(24), child: Center(child: CircularProgressIndicator()));
|
||||
if (snapshot.hasError) return _Notice(message: snapshot.error.toString(), error: true);
|
||||
final items = snapshot.data ?? const [];
|
||||
if (items.isEmpty) return const Padding(padding: EdgeInsets.all(16), child: Text('Noch keine Einträge vorhanden.'));
|
||||
return _ItemList(items: items, workflow: widget.workflow, onEditMember: (member) => _showMemberDialog(context, member));
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Future<void> _showMemberDialog(BuildContext context, Map<String, dynamic> member) async {
|
||||
final first = TextEditingController(text: _value(member, 'firstName', 'firstname'));
|
||||
final last = TextEditingController(text: _value(member, 'lastName', 'lastname'));
|
||||
final email = TextEditingController(text: _value(member, 'email'));
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Mitglied bearbeiten'),
|
||||
content: SizedBox(width: 440, child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
_Field(label: 'Vorname', controller: first), _Field(label: 'Nachname', controller: last), _Field(label: 'E-Mail', controller: email),
|
||||
])),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(dialogContext), child: const Text('Abbrechen')),
|
||||
FilledButton(onPressed: () async {
|
||||
Navigator.pop(dialogContext);
|
||||
await _save(() => _repository.saveMember(widget.clubId, {
|
||||
'id': member['id'], 'firstname': first.text.trim(), 'lastname': last.text.trim(), 'email': email.text.trim(),
|
||||
}), 'Mitglied gespeichert.');
|
||||
}, child: const Text('Speichern')),
|
||||
],
|
||||
),
|
||||
);
|
||||
first.dispose(); last.dispose(); email.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class _Editor extends StatefulWidget {
|
||||
const _Editor({required this.workflow, required this.items, required this.saving, required this.onSave, required this.repository, required this.clubId, required this.onSaved});
|
||||
final ClubWorkflow workflow;
|
||||
final Future<List<Map<String, dynamic>>> items;
|
||||
final bool saving;
|
||||
final Future<void> Function(Future<Object?> Function(), String) onSave;
|
||||
final ClubWorkflowsRepository repository;
|
||||
final int clubId;
|
||||
final VoidCallback onSaved;
|
||||
@override State<_Editor> createState() => _EditorState();
|
||||
}
|
||||
|
||||
class _EditorState extends State<_Editor> {
|
||||
final _form = GlobalKey<FormState>();
|
||||
final _a = TextEditingController(); final _b = TextEditingController(); final _c = TextEditingController();
|
||||
String _status = 'requested'; int? _memberId;
|
||||
@override void dispose() { _a.dispose(); _b.dispose(); _c.dispose(); super.dispose(); }
|
||||
void _clear() { _a.clear(); _b.clear(); _c.clear(); setState(() { _memberId = null; _status = 'requested'; }); }
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final labels = switch (widget.workflow) {
|
||||
ClubWorkflow.training => ('Training anlegen', 'Datum (YYYY-MM-DD)', 'Beginn (HH:MM)', 'Ende (HH:MM)'),
|
||||
ClubWorkflow.members => ('Mitglied anlegen', 'Vorname', 'Nachname', 'E-Mail'),
|
||||
ClubWorkflow.orders => ('Sammelbestellung erfassen', 'Artikel', 'Betrag in €', 'Budget in €'),
|
||||
ClubWorkflow.teams => ('Mannschaft anlegen', 'Name', 'Geplante Liga (optional)', ''),
|
||||
ClubWorkflow.tournaments => ('Turnier anlegen', 'Name', 'Datum (YYYY-MM-DD)', 'Gewinnsätze'),
|
||||
};
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Form(key: _form, child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(labels.$1, style: Theme.of(context).textTheme.titleLarge), const SizedBox(height: 14),
|
||||
if (widget.workflow == ClubWorkflow.orders) FutureBuilder<List<Map<String, dynamic>>(
|
||||
future: widget.items,
|
||||
builder: (context, snapshot) => DropdownButtonFormField<int>(
|
||||
value: _memberId, isExpanded: true, decoration: const InputDecoration(labelText: 'Mitglied'),
|
||||
items: (snapshot.data ?? const []).map((m) => DropdownMenuItem(value: _int(m['id']), child: Text(_memberName(m)))).toList(),
|
||||
onChanged: widget.saving ? null : (value) => setState(() => _memberId = value),
|
||||
validator: (value) => value == null ? 'Bitte Mitglied wählen.' : null,
|
||||
),
|
||||
),
|
||||
if (widget.workflow == ClubWorkflow.orders) const SizedBox(height: 12),
|
||||
Wrap(spacing: 12, runSpacing: 12, children: [
|
||||
SizedBox(width: 250, child: _Field(label: labels.$2, controller: _a, required: true)),
|
||||
SizedBox(width: 220, child: _Field(label: labels.$3, controller: _b, required: widget.workflow != ClubWorkflow.teams)),
|
||||
if (labels.$4.isNotEmpty) SizedBox(width: 180, child: _Field(label: labels.$4, controller: _c, required: widget.workflow == ClubWorkflow.tournaments)),
|
||||
if (widget.workflow == ClubWorkflow.orders) SizedBox(width: 180, child: DropdownButtonFormField<String>(value: _status, decoration: const InputDecoration(labelText: 'Status'), items: const [DropdownMenuItem(value: 'requested', child: Text('Angefragt')), DropdownMenuItem(value: 'ordered', child: Text('Bestellt')), DropdownMenuItem(value: 'received', child: Text('Eingetroffen'))], onChanged: (value) => setState(() => _status = value ?? _status))),
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
Row(children: [
|
||||
FilledButton.icon(onPressed: widget.saving ? null : _submit, icon: widget.saving ? const SizedBox.square(dimension: 16, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.save_outlined), label: const Text('Speichern')),
|
||||
const SizedBox(width: 8), TextButton(onPressed: widget.saving ? null : _clear, child: const Text('Leeren')),
|
||||
]),
|
||||
])),
|
||||
),
|
||||
);
|
||||
}
|
||||
void _submit() {
|
||||
if (!(_form.currentState?.validate() ?? false)) return;
|
||||
final a = _a.text.trim(), b = _b.text.trim(), c = _c.text.trim();
|
||||
switch (widget.workflow) {
|
||||
case ClubWorkflow.training: widget.onSave(() => widget.repository.createTraining(widget.clubId, {'date': a, 'trainingStart': b, 'trainingEnd': c}), 'Training angelegt.');
|
||||
case ClubWorkflow.members: widget.onSave(() => widget.repository.saveMember(widget.clubId, {'firstname': a, 'lastname': b, 'email': c, 'active': true}), 'Mitglied angelegt.');
|
||||
case ClubWorkflow.orders: widget.onSave(() => widget.repository.createOrder(widget.clubId, _memberId!, {'item': a, 'cost': _number(b), 'budget': _number(c), 'status': _status}), 'Bestellung gespeichert.');
|
||||
case ClubWorkflow.teams: widget.onSave(() => widget.repository.createTeam(widget.clubId, {'name': a, 'plannedLeagueName': b}), 'Mannschaft angelegt.');
|
||||
case ClubWorkflow.tournaments: widget.onSave(() => widget.repository.createTournament({'clubId': widget.clubId, 'tournamentName': a, 'date': b, 'winningSets': _int(c) ?? 3}), 'Turnier angelegt.');
|
||||
}
|
||||
_clear();
|
||||
}
|
||||
}
|
||||
|
||||
class _ItemList extends StatelessWidget {
|
||||
const _ItemList({required this.items, required this.workflow, required this.onEditMember}); final List<Map<String, dynamic>> items; final ClubWorkflow workflow; final ValueChanged<Map<String, dynamic>> onEditMember;
|
||||
@override Widget build(BuildContext context) => Column(children: items.take(20).map((item) {
|
||||
final editable = workflow == ClubWorkflow.members;
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
leading: Icon(_icon(workflow), color: AppColors.green), title: Text(_title(item, workflow)), subtitle: _subtitle(item) == null ? null : Text(_subtitle(item)!),
|
||||
trailing: editable ? IconButton(icon: const Icon(Icons.edit_outlined), tooltip: 'Bearbeiten', onPressed: () => onEditMember(item)) : null,
|
||||
);
|
||||
}).toList());
|
||||
}
|
||||
|
||||
class _Field extends StatelessWidget { const _Field({required this.label, required this.controller, this.required = false}); final String label; final TextEditingController controller; final bool required; @override Widget build(BuildContext context) => TextFormField(controller: controller, decoration: InputDecoration(labelText: label), validator: required ? (value) => value == null || value.trim().isEmpty ? 'Pflichtfeld' : null : null); }
|
||||
class _Notice extends StatelessWidget { const _Notice({required this.message, required this.error}); final String message; final bool error; @override Widget build(BuildContext context) => Container(width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration(color: error ? const Color(0xFFFFECEB) : const Color(0xFFE7F4EC), borderRadius: BorderRadius.circular(8)), child: Text(message, style: TextStyle(color: error ? Colors.red.shade800 : AppColors.greenDark))); }
|
||||
List<Map<String, dynamic>> _asRecords(Object? response) { if (response is List) return response.whereType<Map>().map((e) => Map<String, dynamic>.from(e)).toList(); if (response is Map) { final map = Map<String, dynamic>.from(response); for (final key in const ['items', 'members', 'orders', 'data']) { if (map[key] is List) return _asRecords(map[key]); } return [map]; } return const []; }
|
||||
String _value(Map<String, dynamic> map, String first, [String? second]) => (map[first] ?? (second == null ? null : map[second]) ?? '').toString();
|
||||
int? _int(Object? value) => value is int ? value : int.tryParse('$value'); double? _number(String value) => double.tryParse(value.replaceAll(',', '.'));
|
||||
String _memberName(Map<String, dynamic> item) => '${_value(item, 'firstName', 'firstname')} ${_value(item, 'lastName', 'lastname')}'.trim().isEmpty ? 'Mitglied #${item['id']}' : '${_value(item, 'firstName', 'firstname')} ${_value(item, 'lastName', 'lastname')}'.trim();
|
||||
String _title(Map<String, dynamic> item, ClubWorkflow workflow) => switch (workflow) { ClubWorkflow.members || ClubWorkflow.orders => workflow == ClubWorkflow.members ? _memberName(item) : _value(item, 'item'), ClubWorkflow.training => _value(item, 'date'), ClubWorkflow.teams => _value(item, 'name'), ClubWorkflow.tournaments => _value(item, 'name') };
|
||||
String? _subtitle(Map<String, dynamic> item) { for (final key in const ['email', 'trainingStart', 'startTime', 'date', 'status', 'plannedLeagueName']) { final value = item[key]?.toString(); if (value != null && value.isNotEmpty) return value; } return null; }
|
||||
IconData _icon(ClubWorkflow workflow) => switch (workflow) { ClubWorkflow.training => Icons.sports_tennis_outlined, ClubWorkflow.members => Icons.person_outline, ClubWorkflow.orders => Icons.shopping_bag_outlined, ClubWorkflow.teams => Icons.groups_outlined, ClubWorkflow.tournaments => Icons.emoji_events_outlined };
|
||||
@@ -806,7 +806,9 @@ class PDFParserService {
|
||||
}
|
||||
|
||||
static async extractPdfTextWithLayout(filePath) {
|
||||
const { default: pdfjsLib } = await import('pdfjs-dist/legacy/build/pdf.js');
|
||||
// pdfjs-dist v5 publishes the legacy build as an ESM module (.mjs) and
|
||||
// exposes getDocument as a named export (there is no default export).
|
||||
const pdfjsLib = await import('pdfjs-dist/legacy/build/pdf.mjs');
|
||||
const pdfData = new Uint8Array(fs.readFileSync(filePath));
|
||||
const loadingTask = pdfjsLib.getDocument({ data: pdfData, disableWorker: true });
|
||||
const pdf = await loadingTask.promise;
|
||||
|
||||
@@ -70,6 +70,33 @@ class ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends a mutation request where the server response is not part of the UI.
|
||||
Future<void> post(String path, {Object? data}) async {
|
||||
try {
|
||||
await _dio.post<dynamic>(path, data: data);
|
||||
} on DioException catch (error) {
|
||||
throw _toApiException(error);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends a JSON PUT request to one of the existing write APIs.
|
||||
Future<Object?> putData(String path, {Object? data}) async {
|
||||
try {
|
||||
return (await _dio.put<dynamic>(path, data: data)).data;
|
||||
} on DioException catch (error) {
|
||||
throw _toApiException(error);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends a JSON PATCH request to one of the existing write APIs.
|
||||
Future<Object?> patchData(String path, {Object? data}) async {
|
||||
try {
|
||||
return (await _dio.patch<dynamic>(path, data: data)).data;
|
||||
} on DioException catch (error) {
|
||||
throw _toApiException(error);
|
||||
}
|
||||
}
|
||||
|
||||
ApiException _toApiException(DioException error) {
|
||||
final data = error.response?.data;
|
||||
final message = data is Map
|
||||
@@ -0,0 +1,35 @@
|
||||
import '../../core/network/api_client.dart';
|
||||
|
||||
/// Thin adapter for the established write APIs used by the desktop MVP.
|
||||
class ClubWorkflowsRepository {
|
||||
const ClubWorkflowsRepository(this._api);
|
||||
final ApiClient _api;
|
||||
|
||||
Future<Object?> members(int clubId) =>
|
||||
_api.getData('/clubmembers/get/$clubId/false');
|
||||
Future<Object?> training(int clubId) => _api.getData('/diary/$clubId');
|
||||
Future<Object?> teams(int clubId) => _api.getData('/club-teams/club/$clubId');
|
||||
Future<Object?> tournaments(int clubId) => _api.getData('/tournament/$clubId');
|
||||
Future<Object?> orders(int clubId, int memberId) =>
|
||||
_api.getData('/member-orders/$clubId/$memberId');
|
||||
|
||||
Future<Object?> createTraining(int clubId, Map<String, dynamic> body) =>
|
||||
_api.postObject('/diary/$clubId', data: body);
|
||||
Future<Object?> saveMember(int clubId, Map<String, dynamic> body) =>
|
||||
_api.postObject('/clubmembers/set/$clubId', data: body);
|
||||
Future<Object?> createOrder(
|
||||
int clubId,
|
||||
int memberId,
|
||||
Map<String, dynamic> body,
|
||||
) => _api.postObject('/member-orders/$clubId/$memberId', data: body);
|
||||
Future<Object?> updateOrder(
|
||||
int clubId,
|
||||
int memberId,
|
||||
int orderId,
|
||||
Map<String, dynamic> body,
|
||||
) => _api.patchData('/member-orders/$clubId/$memberId/$orderId', data: body);
|
||||
Future<Object?> createTeam(int clubId, Map<String, dynamic> body) =>
|
||||
_api.postObject('/club-teams/club/$clubId', data: body);
|
||||
Future<Object?> createTournament(Map<String, dynamic> body) =>
|
||||
_api.postObject('/tournament', data: body);
|
||||
}
|
||||
31
desktop-app/lib/data/training/training_repository.dart
Normal file
@@ -0,0 +1,31 @@
|
||||
import '../../core/network/api_client.dart';
|
||||
|
||||
/// Narrow client for the existing diary and participant endpoints.
|
||||
class TrainingRepository {
|
||||
const TrainingRepository(this._api);
|
||||
|
||||
final ApiClient _api;
|
||||
|
||||
Future<List<dynamic>> diaryDates(int clubId) =>
|
||||
_api.getList('/diary/$clubId');
|
||||
Future<List<dynamic>> members(int clubId) =>
|
||||
_api.getList('/clubmembers/get/$clubId/false');
|
||||
Future<List<dynamic>> participants(int diaryDateId) =>
|
||||
_api.getList('/participants/$diaryDateId');
|
||||
|
||||
Future<void> addAttendance({
|
||||
required int diaryDateId,
|
||||
required int memberId,
|
||||
}) => _api.post(
|
||||
'/participants/add',
|
||||
data: {'diaryDateId': diaryDateId, 'memberId': memberId},
|
||||
);
|
||||
|
||||
Future<void> removeAttendance({
|
||||
required int diaryDateId,
|
||||
required int memberId,
|
||||
}) => _api.post(
|
||||
'/participants/remove',
|
||||
data: {'diaryDateId': diaryDateId, 'memberId': memberId},
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import '../../../core/network/api_client.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/widgets/product_mark.dart';
|
||||
import '../../overview/presentation/club_overview_page.dart';
|
||||
import '../../training/presentation/training_workbench_page.dart';
|
||||
|
||||
class AppShell extends StatefulWidget {
|
||||
const AppShell({super.key, this.onLogout, this.api, this.clubId});
|
||||
@@ -597,6 +598,16 @@ class _Content extends StatelessWidget {
|
||||
);
|
||||
|
||||
Widget _pageBody(BuildContext context) {
|
||||
if (page.title == 'Training' &&
|
||||
product.product == Product.trainer &&
|
||||
api != null &&
|
||||
clubId != null) {
|
||||
return TrainingWorkbenchPage(
|
||||
key: ValueKey('training-workbench-$clubId'),
|
||||
clubId: clubId!,
|
||||
api: api!,
|
||||
);
|
||||
}
|
||||
final kind = _overviewKind();
|
||||
if (kind != null && api != null && clubId != null) {
|
||||
return Container(
|
||||
@@ -0,0 +1,330 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/widgets/async_content.dart';
|
||||
import '../../../data/training/training_repository.dart';
|
||||
|
||||
/// Native workbench for reviewing a diary day and maintaining attendance.
|
||||
class TrainingWorkbenchPage extends StatefulWidget {
|
||||
const TrainingWorkbenchPage({
|
||||
super.key,
|
||||
required this.clubId,
|
||||
required this.api,
|
||||
});
|
||||
|
||||
final int clubId;
|
||||
final ApiClient api;
|
||||
|
||||
@override
|
||||
State<TrainingWorkbenchPage> createState() => _TrainingWorkbenchPageState();
|
||||
}
|
||||
|
||||
class _TrainingWorkbenchPageState extends State<TrainingWorkbenchPage> {
|
||||
late final TrainingRepository _repository;
|
||||
late Future<_InitialTrainingData> _initialRequest;
|
||||
Future<List<dynamic>>? _participantsRequest;
|
||||
int? _selectedDateId;
|
||||
int? _pendingMemberId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_repository = TrainingRepository(widget.api);
|
||||
_initialRequest = _loadInitial();
|
||||
}
|
||||
|
||||
Future<_InitialTrainingData> _loadInitial() async {
|
||||
final values = await Future.wait([
|
||||
_repository.diaryDates(widget.clubId),
|
||||
_repository.members(widget.clubId),
|
||||
]);
|
||||
return _InitialTrainingData(
|
||||
dates: _maps(values[0]),
|
||||
members: _maps(values[1]),
|
||||
);
|
||||
}
|
||||
|
||||
void _selectDate(int dateId) {
|
||||
setState(() {
|
||||
_selectedDateId = dateId;
|
||||
_participantsRequest = _repository.participants(dateId);
|
||||
});
|
||||
}
|
||||
|
||||
void _retry() => setState(() {
|
||||
_selectedDateId = null;
|
||||
_participantsRequest = null;
|
||||
_initialRequest = _loadInitial();
|
||||
});
|
||||
|
||||
Future<void> _changeAttendance(int dateId, int memberId, bool present) async {
|
||||
setState(() => _pendingMemberId = memberId);
|
||||
try {
|
||||
if (present) {
|
||||
await _repository.removeAttendance(
|
||||
diaryDateId: dateId,
|
||||
memberId: memberId,
|
||||
);
|
||||
} else {
|
||||
await _repository.addAttendance(
|
||||
diaryDateId: dateId,
|
||||
memberId: memberId,
|
||||
);
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() => _participantsRequest = _repository.participants(dateId));
|
||||
} catch (error) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Anwesenheit konnte nicht geändert werden: $error'),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _pendingMemberId = null);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => FutureBuilder<_InitialTrainingData>(
|
||||
future: _initialRequest,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return const SizedBox(height: 300, child: AppLoadingView());
|
||||
}
|
||||
if (snapshot.hasError) {
|
||||
return SizedBox(
|
||||
height: 300,
|
||||
child: AppErrorView(
|
||||
message: snapshot.error.toString(),
|
||||
onRetry: _retry,
|
||||
),
|
||||
);
|
||||
}
|
||||
final data = snapshot.requireData;
|
||||
if (data.dates.isEmpty) return const _NoTrainingDates();
|
||||
final currentDateId = _selectedDateId ?? _id(data.dates.first);
|
||||
if (currentDateId == null) return const _NoTrainingDates();
|
||||
_participantsRequest ??= _repository.participants(currentDateId);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_DateSelector(
|
||||
dates: data.dates,
|
||||
selectedDateId: currentDateId,
|
||||
onSelected: _selectDate,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FutureBuilder<List<dynamic>>(
|
||||
future: _participantsRequest,
|
||||
builder: (context, participantsSnapshot) {
|
||||
if (participantsSnapshot.connectionState !=
|
||||
ConnectionState.done) {
|
||||
return const SizedBox(height: 220, child: AppLoadingView());
|
||||
}
|
||||
if (participantsSnapshot.hasError) {
|
||||
return SizedBox(
|
||||
height: 220,
|
||||
child: AppErrorView(
|
||||
message: participantsSnapshot.error.toString(),
|
||||
onRetry: () => _selectDate(currentDateId),
|
||||
),
|
||||
);
|
||||
}
|
||||
return _AttendanceList(
|
||||
dateId: currentDateId,
|
||||
members: data.members,
|
||||
participants: _maps(participantsSnapshot.data),
|
||||
pendingMemberId: _pendingMemberId,
|
||||
onChanged: _changeAttendance,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class _InitialTrainingData {
|
||||
const _InitialTrainingData({required this.dates, required this.members});
|
||||
final List<Map<String, dynamic>> dates;
|
||||
final List<Map<String, dynamic>> members;
|
||||
}
|
||||
|
||||
class _DateSelector extends StatelessWidget {
|
||||
const _DateSelector({
|
||||
required this.dates,
|
||||
required this.selectedDateId,
|
||||
required this.onSelected,
|
||||
});
|
||||
final List<Map<String, dynamic>> dates;
|
||||
final int selectedDateId;
|
||||
final ValueChanged<int> onSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Trainingstag', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 10),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: dates.map((date) {
|
||||
final id = _id(date);
|
||||
if (id == null) return const SizedBox.shrink();
|
||||
return ChoiceChip(
|
||||
label: Text(_dateLabel(date)),
|
||||
selected: id == selectedDateId,
|
||||
onSelected: (_) => onSelected(id),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class _AttendanceList extends StatelessWidget {
|
||||
const _AttendanceList({
|
||||
required this.dateId,
|
||||
required this.members,
|
||||
required this.participants,
|
||||
required this.pendingMemberId,
|
||||
required this.onChanged,
|
||||
});
|
||||
final int dateId;
|
||||
final List<Map<String, dynamic>> members;
|
||||
final List<Map<String, dynamic>> participants;
|
||||
final int? pendingMemberId;
|
||||
final Future<void> Function(int, int, bool) onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final presentIds = participants
|
||||
.where((participant) => participant['attendanceStatus'] == 'present')
|
||||
.map(_id)
|
||||
.whereType<int>()
|
||||
.toSet();
|
||||
final sortedMembers = [...members]
|
||||
..sort((a, b) => _memberName(a).compareTo(_memberName(b)));
|
||||
if (sortedMembers.isEmpty) {
|
||||
return const _EmptyAttendance(
|
||||
message: 'Für diesen Verein sind keine Mitglieder vorhanden.',
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Anwesenheit', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${presentIds.length} anwesend',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(color: AppColors.muted),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
border: Border.all(color: AppColors.line),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: sortedMembers.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final member = sortedMembers[index];
|
||||
final memberId = _id(member);
|
||||
if (memberId == null) return const SizedBox.shrink();
|
||||
final present = presentIds.contains(memberId);
|
||||
final pending = pendingMemberId == memberId;
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
child: Text(_initials(_memberName(member))),
|
||||
),
|
||||
title: Text(_memberName(member)),
|
||||
subtitle: Text(present ? 'Anwesend' : 'Nicht eingetragen'),
|
||||
trailing: pending
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Switch(
|
||||
value: present,
|
||||
onChanged: (_) => onChanged(dateId, memberId, present),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NoTrainingDates extends StatelessWidget {
|
||||
const _NoTrainingDates();
|
||||
@override
|
||||
Widget build(BuildContext context) => const _EmptyAttendance(
|
||||
message: 'Es sind noch keine Trainingstage im Tagebuch vorhanden.',
|
||||
);
|
||||
}
|
||||
|
||||
class _EmptyAttendance extends StatelessWidget {
|
||||
const _EmptyAttendance({required this.message});
|
||||
final String message;
|
||||
@override
|
||||
Widget build(BuildContext context) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Text(message, textAlign: TextAlign.center),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _maps(Object? value) => value is List
|
||||
? value
|
||||
.whereType<Map>()
|
||||
.map((entry) => Map<String, dynamic>.from(entry))
|
||||
.toList()
|
||||
: const [];
|
||||
|
||||
int? _id(Map<String, dynamic> value) => int.tryParse('${value['id'] ?? ''}');
|
||||
|
||||
String _memberName(Map<String, dynamic> member) {
|
||||
final name =
|
||||
[
|
||||
member['firstName'] ?? member['firstname'],
|
||||
member['lastName'] ?? member['lastname'],
|
||||
]
|
||||
.where((part) => part != null && part.toString().trim().isNotEmpty)
|
||||
.join(' ')
|
||||
.trim();
|
||||
return name.isNotEmpty
|
||||
? name
|
||||
: (member['fullName']?.toString() ?? 'Mitglied ${member['id']}');
|
||||
}
|
||||
|
||||
String _initials(String name) => name
|
||||
.split(RegExp(r'\s+'))
|
||||
.where((part) => part.isNotEmpty)
|
||||
.take(2)
|
||||
.map((part) => part[0])
|
||||
.join()
|
||||
.toUpperCase();
|
||||
|
||||
String _dateLabel(Map<String, dynamic> date) {
|
||||
final raw = date['date']?.toString();
|
||||
if (raw == null || raw.length < 10) return 'Training';
|
||||
final day = raw.substring(8, 10);
|
||||
final month = raw.substring(5, 7);
|
||||
final year = raw.substring(0, 4);
|
||||
final start = date['trainingStart']?.toString();
|
||||
return '$day.$month.$year${start == null || start.isEmpty ? '' : ' · ${start.substring(0, start.length.clamp(0, 5))}'}';
|
||||
}
|
||||
@@ -1,5 +1,17 @@
|
||||
# Project-level configuration.
|
||||
cmake_minimum_required(VERSION 3.13)
|
||||
|
||||
# Some Linux environments export CXX=clang++ although clang is not installed.
|
||||
# Prefer the available GNU compiler in that situation before CMake enables C++.
|
||||
find_program(TT_TAGEBUCH_GCC_EXECUTABLE gcc)
|
||||
find_program(TT_TAGEBUCH_GXX_EXECUTABLE g++)
|
||||
if(TT_TAGEBUCH_GCC_EXECUTABLE)
|
||||
set(CMAKE_C_COMPILER "${TT_TAGEBUCH_GCC_EXECUTABLE}" CACHE FILEPATH "C compiler" FORCE)
|
||||
endif()
|
||||
if(TT_TAGEBUCH_GXX_EXECUTABLE)
|
||||
set(CMAKE_CXX_COMPILER "${TT_TAGEBUCH_GXX_EXECUTABLE}" CACHE FILEPATH "C++ compiler" FORCE)
|
||||
endif()
|
||||
|
||||
project(runner LANGUAGES CXX)
|
||||
|
||||
# The name of the executable created for the application. Change this to change
|
||||
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 520 B After Width: | Height: | Size: 520 B |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |