diff --git a/backend/desktop-app/lib/core/network/api_client.dart b/backend/desktop-app/lib/core/network/api_client.dart index f5d50850..8fffaa74 100644 --- a/backend/desktop-app/lib/core/network/api_client.dart +++ b/backend/desktop-app/lib/core/network/api_client.dart @@ -31,21 +31,33 @@ class ApiClient { final Dio _dio; - Future> getObject(String path) async { + /// Reads either object or list responses. Use this for endpoints whose + /// response shape is intentionally determined by the server. + Future getData(String path) async { try { - final response = await _dio.get(path); - return Map.from(response.data as Map); + return await _dio.get(path).then((response) => response.data); } on DioException catch (error) { throw _toApiException(error); } } + Future> getObject(String path) async { + try { + return Map.from((await getData(path)) as Map); + } on TypeError { + throw const ApiException( + 'Der Server hat ein unerwartetes Datenformat geliefert.', + ); + } + } + Future> getList(String path) async { try { - final response = await _dio.get(path); - return List.from(response.data as List); - } on DioException catch (error) { - throw _toApiException(error); + return List.from((await getData(path)) as List); + } on TypeError { + throw const ApiException( + 'Der Server hat ein unerwartetes Datenformat geliefert.', + ); } } diff --git a/backend/desktop-app/lib/data/club_overview/club_overview_repository.dart b/backend/desktop-app/lib/data/club_overview/club_overview_repository.dart new file mode 100644 index 00000000..975c4ec3 --- /dev/null +++ b/backend/desktop-app/lib/data/club_overview/club_overview_repository.dart @@ -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 dashboard(int clubId) => + _api.getData('/club-dashboard/$clubId'); + Future memberDashboard(int clubId) => + _api.getData('/clubmembers/dashboard/$clubId'); + Future calendarEvents(int clubId) => + _api.getData('/calendar-events/$clubId'); + Future schedule(int clubId) => + _api.getData('/matches/leagues/current/$clubId'); + Future members(int clubId) => + _api.getData('/clubmembers/get/$clubId/false'); + Future training(int clubId) => _api.getData('/diary/$clubId'); +} diff --git a/backend/desktop-app/lib/features/overview/presentation/club_overview_page.dart b/backend/desktop-app/lib/features/overview/presentation/club_overview_page.dart new file mode 100644 index 00000000..b138854b --- /dev/null +++ b/backend/desktop-app/lib/features/overview/presentation/club_overview_page.dart @@ -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 createState() => _ClubOverviewPageState(); +} + +class _ClubOverviewPageState extends State { + late Future _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 _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( + 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> _records(Object? data) { + if (data is List) { + return data + .whereType() + .map((item) => Map.from(item)) + .toList(); + } + if (data is Map) { + final value = Map.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> 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 data, List 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().join(' '); + } + return null; +} + +String? _summary(Map data, String title) { + final values = []; + 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(' · '); +} diff --git a/backend/desktop-app/lib/features/shell/presentation/app_shell.dart b/backend/desktop-app/lib/features/shell/presentation/app_shell.dart index f3785e9f..4dfaa681 100644 --- a/backend/desktop-app/lib/features/shell/presentation/app_shell.dart +++ b/backend/desktop-app/lib/features/shell/presentation/app_shell.dart @@ -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 createState() => _AppShellState(); @@ -50,7 +54,12 @@ class _AppShellState extends State { 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, + }; + } } diff --git a/backend/desktop-app/lib/main.dart b/backend/desktop-app/lib/main.dart index a15206cf..e9d86b3c 100644 --- a/backend/desktop-app/lib/main.dart +++ b/backend/desktop-app/lib/main.dart @@ -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 createState() => _TtTagebuchAppState(); @@ -62,16 +67,21 @@ class _TtTagebuchAppState extends State { 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( diff --git a/docs/FLUTTER_DESKTOP_APP_PLAN.md b/docs/FLUTTER_DESKTOP_APP_PLAN.md index e9c3962e..cc63e03f 100644 --- a/docs/FLUTTER_DESKTOP_APP_PLAN.md +++ b/docs/FLUTTER_DESKTOP_APP_PLAN.md @@ -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