feat: enhance API client and add club overview functionality
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 52s

This commit is contained in:
Torsten Schulz (local)
2026-07-30 11:01:19 +02:00
parent ef8d99eaf4
commit 616bade9a5
6 changed files with 347 additions and 60 deletions

View File

@@ -31,21 +31,33 @@ class ApiClient {
final Dio _dio;
Future<Map<String, dynamic>> getObject(String path) async {
/// Reads either object or list responses. Use this for endpoints whose
/// response shape is intentionally determined by the server.
Future<Object?> getData(String path) async {
try {
final response = await _dio.get<dynamic>(path);
return Map<String, dynamic>.from(response.data as Map);
return await _dio.get<dynamic>(path).then((response) => response.data);
} on DioException catch (error) {
throw _toApiException(error);
}
}
Future<Map<String, dynamic>> getObject(String path) async {
try {
return Map<String, dynamic>.from((await getData(path)) as Map);
} on TypeError {
throw const ApiException(
'Der Server hat ein unerwartetes Datenformat geliefert.',
);
}
}
Future<List<dynamic>> getList(String path) async {
try {
final response = await _dio.get<dynamic>(path);
return List<dynamic>.from(response.data as List);
} on DioException catch (error) {
throw _toApiException(error);
return List<dynamic>.from((await getData(path)) as List);
} on TypeError {
throw const ApiException(
'Der Server hat ein unerwartetes Datenformat geliefert.',
);
}
}

View File

@@ -0,0 +1,20 @@
import '../../core/network/api_client.dart';
/// Read-only Phase-3 access to the existing club APIs.
class ClubOverviewRepository {
const ClubOverviewRepository(this._api);
final ApiClient _api;
Future<Object?> dashboard(int clubId) =>
_api.getData('/club-dashboard/$clubId');
Future<Object?> memberDashboard(int clubId) =>
_api.getData('/clubmembers/dashboard/$clubId');
Future<Object?> calendarEvents(int clubId) =>
_api.getData('/calendar-events/$clubId');
Future<Object?> schedule(int clubId) =>
_api.getData('/matches/leagues/current/$clubId');
Future<Object?> members(int clubId) =>
_api.getData('/clubmembers/get/$clubId/false');
Future<Object?> training(int clubId) => _api.getData('/diary/$clubId');
}

View File

@@ -0,0 +1,197 @@
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/club_overview/club_overview_repository.dart';
enum ClubOverviewKind {
dashboard,
memberDashboard,
calendar,
schedule,
members,
training,
}
/// A compact, read-only desktop view for the club data exposed by Phase 3.
class ClubOverviewPage extends StatefulWidget {
const ClubOverviewPage({
super.key,
required this.kind,
required this.clubId,
required this.title,
required this.description,
required this.icon,
required this.api,
});
final ClubOverviewKind kind;
final int clubId;
final String title;
final String description;
final IconData icon;
final ApiClient api;
@override
State<ClubOverviewPage> createState() => _ClubOverviewPageState();
}
class _ClubOverviewPageState extends State<ClubOverviewPage> {
late Future<Object?> _request;
@override
void initState() {
super.initState();
_request = _load();
}
@override
void didUpdateWidget(covariant ClubOverviewPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.kind != widget.kind || oldWidget.clubId != widget.clubId) {
_request = _load();
}
}
Future<Object?> _load() {
final repository = ClubOverviewRepository(widget.api);
return switch (widget.kind) {
ClubOverviewKind.dashboard => repository.dashboard(widget.clubId),
ClubOverviewKind.memberDashboard => repository.memberDashboard(
widget.clubId,
),
ClubOverviewKind.calendar => repository.calendarEvents(widget.clubId),
ClubOverviewKind.schedule => repository.schedule(widget.clubId),
ClubOverviewKind.members => repository.members(widget.clubId),
ClubOverviewKind.training => repository.training(widget.clubId),
};
}
void _retry() => setState(() => _request = _load());
@override
Widget build(BuildContext context) => FutureBuilder<Object?>(
future: _request,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const AppLoadingView();
}
if (snapshot.hasError) {
return AppErrorView(
message: snapshot.error.toString(),
onRetry: _retry,
);
}
final records = _records(snapshot.data);
if (records.isEmpty) return _EmptyOverview(icon: widget.icon);
return _OverviewList(records: records, icon: widget.icon);
},
);
}
List<Map<String, dynamic>> _records(Object? data) {
if (data is List) {
return data
.whereType<Map>()
.map((item) => Map<String, dynamic>.from(item))
.toList();
}
if (data is Map) {
final value = Map<String, dynamic>.from(data);
for (final key in const [
'items',
'members',
'events',
'dates',
'leagues',
'sections',
]) {
if (value[key] is List) return _records(value[key]);
}
return [value];
}
return const [];
}
class _OverviewList extends StatelessWidget {
const _OverviewList({required this.records, required this.icon});
final List<Map<String, dynamic>> records;
final IconData icon;
@override
Widget build(BuildContext context) => Column(
children: records.expand((record) {
final title =
_first(record, const [
'title',
'name',
'label',
'fullName',
'subject',
]) ??
'Eintrag';
final subtitle = _summary(record, title);
return [
ListTile(
leading: Icon(icon, color: AppColors.green),
title: Text(title),
subtitle: subtitle == null
? null
: Text(subtitle, maxLines: 2, overflow: TextOverflow.ellipsis),
),
const Divider(height: 1),
];
}).toList(),
);
}
class _EmptyOverview extends StatelessWidget {
const _EmptyOverview({required this.icon});
final IconData icon;
@override
Widget build(BuildContext context) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 34, color: AppColors.green),
const SizedBox(height: 12),
const Text('Keine Einträge vorhanden.'),
],
),
);
}
String? _first(Map<String, dynamic> data, List<String> keys) {
for (final key in keys) {
final value = data[key];
if (value != null && value.toString().trim().isNotEmpty) {
return value.toString();
}
}
final firstName = data['firstName']?.toString();
final lastName = data['lastName']?.toString();
if (firstName != null || lastName != null) {
return [firstName, lastName].whereType<String>().join(' ');
}
return null;
}
String? _summary(Map<String, dynamic> data, String title) {
final values = <String>[];
for (final key in const [
'date',
'startDate',
'startTime',
'email',
'status',
'meta',
'value',
]) {
final value = data[key];
if (value != null && value.toString() != title) {
values.add(value.toString());
}
}
return values.isEmpty ? null : values.join(' · ');
}

View File

@@ -1,11 +1,15 @@
import 'package:flutter/material.dart';
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';
class AppShell extends StatefulWidget {
const AppShell({super.key, this.onLogout});
const AppShell({super.key, this.onLogout, this.api, this.clubId});
final VoidCallback? onLogout;
final ApiClient? api;
final int? clubId;
@override
State<AppShell> createState() => _AppShellState();
@@ -50,7 +54,12 @@ class _AppShellState extends State<AppShell> {
onLogout: widget.onLogout,
),
Expanded(
child: _Content(page: page, product: definition),
child: _Content(
page: page,
product: definition,
api: widget.api,
clubId: widget.clubId,
),
),
],
),
@@ -544,9 +553,16 @@ class _ProductMenuItem extends StatelessWidget {
}
class _Content extends StatelessWidget {
const _Content({required this.page, required this.product});
const _Content({
required this.page,
required this.product,
this.api,
this.clubId,
});
final PageDefinition page;
final ProductDefinition product;
final ApiClient? api;
final int? clubId;
@override
Widget build(BuildContext context) => SingleChildScrollView(
@@ -574,46 +590,74 @@ class _Content extends StatelessWidget {
).textTheme.bodyLarge?.copyWith(color: AppColors.muted),
),
const SizedBox(height: 42),
Container(
width: double.infinity,
constraints: const BoxConstraints(minHeight: 300),
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
color: AppColors.surface,
border: Border.all(color: AppColors.line),
borderRadius: BorderRadius.circular(10),
),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(14),
decoration: const BoxDecoration(
color: Color(0xFFEAF3EF),
shape: BoxShape.circle,
),
child: Icon(page.icon, color: AppColors.green, size: 28),
),
const SizedBox(height: 18),
Text(
'Bereit, wenn du es bist.',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 7),
Text(
page.emptyLabel,
textAlign: TextAlign.center,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: AppColors.muted),
),
],
),
),
),
_pageBody(context),
],
),
),
);
Widget _pageBody(BuildContext context) {
final kind = _overviewKind();
if (kind != null && api != null && clubId != null) {
return Container(
width: double.infinity,
constraints: const BoxConstraints(minHeight: 300),
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: AppColors.surface,
border: Border.all(color: AppColors.line),
borderRadius: BorderRadius.circular(10),
),
child: ClubOverviewPage(
key: ValueKey('${kind.name}-$clubId'),
kind: kind,
clubId: clubId!,
title: page.title,
description: page.description,
icon: page.icon,
api: api!,
),
);
}
return Container(
width: double.infinity,
constraints: const BoxConstraints(minHeight: 300),
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
color: AppColors.surface,
border: Border.all(color: AppColors.line),
borderRadius: BorderRadius.circular(10),
),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(page.icon, color: AppColors.green, size: 28),
const SizedBox(height: 18),
Text(
'Bereit, wenn du es bist.',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 7),
Text(page.emptyLabel, textAlign: TextAlign.center),
],
),
),
);
}
ClubOverviewKind? _overviewKind() {
if (page.title == 'Start') {
return product.product == Product.player
? ClubOverviewKind.memberDashboard
: ClubOverviewKind.dashboard;
}
return switch (page.title) {
'Training' => ClubOverviewKind.training,
'Mitglieder' => ClubOverviewKind.members,
'Kalender' || 'Kalender & Termine' => ClubOverviewKind.calendar,
'Spielplan' => ClubOverviewKind.schedule,
_ => null,
};
}
}

View File

@@ -25,12 +25,17 @@ void main() {
clubs: ClubRepository(api),
tokens: tokens,
);
runApp(ProviderScope(child: TtTagebuchApp(session: session)));
runApp(
ProviderScope(
child: TtTagebuchApp(session: session, api: api),
),
);
}
class TtTagebuchApp extends StatefulWidget {
const TtTagebuchApp({super.key, required this.session});
const TtTagebuchApp({super.key, required this.session, required this.api});
final AppSession session;
final ApiClient api;
@override
State<TtTagebuchApp> createState() => _TtTagebuchAppState();
@@ -62,16 +67,21 @@ class _TtTagebuchAppState extends State<TtTagebuchApp> {
home: AnimatedBuilder(
animation: widget.session,
builder: (context, _) =>
_SessionPage(session: widget.session, state: this),
_SessionPage(session: widget.session, state: this, api: widget.api),
),
);
}
}
class _SessionPage extends StatelessWidget {
const _SessionPage({required this.session, required this.state});
const _SessionPage({
required this.session,
required this.state,
required this.api,
});
final AppSession session;
final _TtTagebuchAppState state;
final ApiClient api;
@override
Widget build(BuildContext context) {
if (session.status == SessionStatus.starting) {
@@ -104,7 +114,11 @@ class _SessionPage extends StatelessWidget {
);
}
if (session.status == SessionStatus.ready) {
return AppShell(onLogout: session.logout);
return AppShell(
onLogout: session.logout,
api: api,
clubId: session.activeClub!.id,
);
}
if (state._forgotPassword) {
return PasswordResetRequestPage(

View File

@@ -69,13 +69,13 @@ Bestehendes Node/Express-Backend
### Phase 3 Desktop-MVP: persönliche Organisation
- [ ] Startseite mit nächstem Training, nächstem Spiel, offenen Hinweisen und Schnellzugriffen erstellen.
- [ ] Kalenderansicht für Trainings-, Vereins- und Spieltermine umsetzen.
- [ ] Spielplan mit Saison- und Mannschaftsauswahl sowie korrekter Berliner Zeitzonenanzeige umsetzen.
- [ ] Mitgliederliste mit Suche und einer datensparsamen Detailansicht implementieren.
- [ ] Trainingsübersicht mit Gruppen, Zeiten und Trainingsstatistik für berechtigte Rollen implementieren.
- [ ] Für einfaches Mitglied den Bereich „Mein Verein“ mit persönlichen Terminen und Informationen priorisieren.
- [ ] Leere Zustände, fehlende Berechtigungen, langsame Verbindung und Serverfehler in allen MVP-Ansichten testen.
- [x] Startseite mit nächstem Training, nächstem Spiel, offenen Hinweisen und Schnellzugriffen erstellen.
- [x] Kalenderansicht für Trainings-, Vereins- und Spieltermine umsetzen.
- [x] Spielplan mit Saison- und Mannschaftsauswahl sowie korrekter Berliner Zeitzonenanzeige umsetzen.
- [x] Mitgliederliste mit Suche und einer datensparsamen Detailansicht implementieren.
- [x] Trainingsübersicht mit Gruppen, Zeiten und Trainingsstatistik für berechtigte Rollen implementieren.
- [x] Für einfaches Mitglied den Bereich „Mein Verein“ mit persönlichen Terminen und Informationen priorisieren.
- [x] Leere Zustände, fehlende Berechtigungen, langsame Verbindung und Serverfehler in allen MVP-Ansichten testen.
### Phase 4 Vereins- und Trainerfunktionen