feat: enhance API client and add club overview functionality
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 52s
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 52s
This commit is contained in:
@@ -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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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(' · ');
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user