- android app
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 55s

- code import fix
This commit is contained in:
Torsten Schulz (local)
2026-08-10 15:15:11 +02:00
parent 616bade9a5
commit 5f2de5e671
93 changed files with 693 additions and 1 deletions

View File

@@ -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 };

View File

@@ -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;

View File

@@ -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

View File

@@ -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);
}

View 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},
);
}

View File

@@ -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(

View File

@@ -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))}'}';
}

View File

@@ -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

View File

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB