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 c3f93ad8..49684f8f 100644 --- a/backend/desktop-app/lib/features/shell/presentation/app_shell.dart +++ b/backend/desktop-app/lib/features/shell/presentation/app_shell.dart @@ -5,61 +5,36 @@ import '../../../core/widgets/product_mark.dart'; class AppShell extends StatefulWidget { const AppShell({super.key}); + @override State createState() => _AppShellState(); } class _AppShellState extends State { + Product _product = Product.trainer; int _selectedIndex = 0; - static const _pages = [ - _PageInfo( - 'Start', - 'Dein Überblick für heute.', - Icons.home_outlined, - 'Hier entsteht dein persönlicher Vereinsüberblick.', - ), - _PageInfo( - 'Mein Verein', - 'Menschen, Mannschaften und Vereinsleben.', - Icons.shield_outlined, - 'Vereinsinformationen werden hier zusammengeführt.', - ), - _PageInfo( - 'Kalender', - 'Alle Termine deines Vereins im Blick.', - Icons.calendar_month_outlined, - 'Noch keine Termine zum Anzeigen.', - ), - _PageInfo( - 'Spielplan', - 'Begegnungen, Ergebnisse und Tabellen.', - Icons.sports_score_outlined, - 'Noch keine Spiele zum Anzeigen.', - ), - _PageInfo( - 'Mitglieder', - 'Die Menschen in deinem Verein.', - Icons.groups_outlined, - 'Noch keine Mitglieder zum Anzeigen.', - ), - _PageInfo( - 'Training', - 'Planung und Teilnahme fürs Training.', - Icons.sports_tennis_outlined, - 'Noch keine Trainingseinheiten zum Anzeigen.', - ), - ]; + + ProductDefinition get _definition => ProductDefinition.forProduct(_product); + + void _changeProduct(Product product) { + setState(() { + _product = product; + _selectedIndex = 0; + }); + } @override Widget build(BuildContext context) { - final page = _pages[_selectedIndex]; + final definition = _definition; + final page = definition.pages[_selectedIndex]; return Scaffold( body: LayoutBuilder( builder: (context, constraints) { - final compact = constraints.maxWidth < 860; + final compact = constraints.maxWidth < 920; return Row( children: [ _NavigationRail( + definition: definition, selectedIndex: _selectedIndex, compact: compact, onSelected: (value) => setState(() => _selectedIndex = value), @@ -67,8 +42,14 @@ class _AppShellState extends State { Expanded( child: Column( children: [ - _TopBar(compact: compact), - Expanded(child: _Content(page: page)), + _TopBar( + definition: definition, + compact: compact, + onProductSelected: _changeProduct, + ), + Expanded( + child: _Content(page: page, product: definition), + ), ], ), ), @@ -80,34 +61,212 @@ class _AppShellState extends State { } } +enum Product { trainer, club, player } + +class ProductDefinition { + const ProductDefinition({ + required this.product, + required this.name, + required this.contextLabel, + required this.contextName, + required this.contextIcon, + required this.pages, + required this.settingsLabel, + }); + + final Product product; + final String name; + final String contextLabel; + final String contextName; + final IconData contextIcon; + final List pages; + final String settingsLabel; + + static ProductDefinition forProduct(Product product) => switch (product) { + Product.trainer => const ProductDefinition( + product: Product.trainer, + name: 'Trainings-Tagebuch', + contextLabel: 'Trainerbereich', + contextName: 'TTC Musterstadt', + contextIcon: Icons.sports_tennis_outlined, + settingsLabel: 'Einstellungen', + pages: [ + PageDefinition( + 'Start', + 'Dein Überblick für heute.', + Icons.home_outlined, + 'Hier entsteht dein persönlicher Trainingsüberblick.', + ), + PageDefinition( + 'Training', + 'Einheiten planen und Teilnahme begleiten.', + Icons.sports_tennis_outlined, + 'Noch keine Trainingseinheiten zum Anzeigen.', + ), + PageDefinition( + 'Mitglieder', + 'Spielerinnen und Spieler im Blick.', + Icons.groups_outlined, + 'Noch keine Mitglieder zum Anzeigen.', + ), + PageDefinition( + 'Kalender', + 'Termine für Training und Verein.', + Icons.calendar_month_outlined, + 'Noch keine Termine zum Anzeigen.', + ), + PageDefinition( + 'Spielplan', + 'Begegnungen, Ergebnisse und Tabellen.', + Icons.sports_score_outlined, + 'Noch keine Spiele zum Anzeigen.', + ), + PageDefinition( + 'Turniere & Statistik', + 'Leistung und Wettkämpfe nachvollziehen.', + Icons.query_stats_outlined, + 'Noch keine Turnier- oder Statistikdaten zum Anzeigen.', + ), + ], + ), + Product.club => const ProductDefinition( + product: Product.club, + name: 'TT Verein', + contextLabel: 'Vereinsverwaltung', + contextName: 'TTC Musterstadt', + contextIcon: Icons.location_city_outlined, + settingsLabel: 'Verein einstellen', + pages: [ + PageDefinition( + 'Start', + 'Alles Wichtige aus deinem Verein.', + Icons.home_outlined, + 'Hier entsteht dein Vereinsüberblick.', + ), + PageDefinition( + 'Mitglieder', + 'Menschen, Rollen und Mitgliedschaften.', + Icons.groups_outlined, + 'Noch keine Mitglieder zum Anzeigen.', + ), + PageDefinition( + 'Kalender & Termine', + 'Veranstaltungen und Vereinsalltag koordinieren.', + Icons.calendar_month_outlined, + 'Noch keine Termine zum Anzeigen.', + ), + PageDefinition( + 'Kommunikation', + 'Neuigkeiten und Nachrichten im Verein.', + Icons.forum_outlined, + 'Noch keine Nachrichten zum Anzeigen.', + ), + PageDefinition( + 'Aufgaben', + 'Was im Verein als Nächstes ansteht.', + Icons.task_alt_outlined, + 'Noch keine Aufgaben zum Anzeigen.', + ), + PageDefinition( + 'Finanzen', + 'Beiträge und Vereinsfinanzen im Überblick.', + Icons.account_balance_wallet_outlined, + 'Noch keine Finanzdaten zum Anzeigen.', + ), + ], + ), + Product.player => const ProductDefinition( + product: Product.player, + name: 'Mein TT', + contextLabel: 'Mein Tischtennis', + contextName: 'Torsten Muster', + contextIcon: Icons.person_outline_rounded, + settingsLabel: 'Persönliche Einstellungen', + pages: [ + PageDefinition( + 'Start', + 'Dein persönlicher Tischtennis-Alltag.', + Icons.home_outlined, + 'Hier entsteht dein persönlicher Überblick.', + ), + PageDefinition( + 'Kalender', + 'Deine Trainings, Spiele und Termine.', + Icons.calendar_month_outlined, + 'Noch keine Termine zum Anzeigen.', + ), + PageDefinition( + 'Verknüpfte Konten', + 'Deine verbundenen TT-Zugänge.', + Icons.link_outlined, + 'Noch keine Konten zum Anzeigen.', + ), + PageDefinition( + 'Bestellungen', + 'Deine Bestellungen und Anfragen.', + Icons.receipt_long_outlined, + 'Noch keine Bestellungen zum Anzeigen.', + ), + PageDefinition( + 'Persönliche Einstellungen', + 'Dein Profil und deine Präferenzen.', + Icons.settings_outlined, + 'Noch keine Einstellungen zum Anzeigen.', + ), + ], + ), + }; +} + +class PageDefinition { + const PageDefinition( + this.title, + this.description, + this.icon, + this.emptyLabel, + ); + + final String title; + final String description; + final IconData icon; + final String emptyLabel; +} + class _NavigationRail extends StatelessWidget { const _NavigationRail({ + required this.definition, required this.selectedIndex, required this.compact, required this.onSelected, }); + + final ProductDefinition definition; final int selectedIndex; final bool compact; final ValueChanged onSelected; + @override Widget build(BuildContext context) => Container( - width: compact ? 76 : 226, + width: compact ? 76 : 242, color: AppColors.greenDark, child: Column( children: [ Padding( - padding: EdgeInsets.fromLTRB(compact ? 16 : 24, 25, 16, 26), + padding: EdgeInsets.fromLTRB(compact ? 16 : 24, 25, 16, 21), child: Row( children: [ const ProductMark(light: true, size: 33), if (!compact) const SizedBox(width: 10), if (!compact) - const Text( - 'TT-Tagebuch', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.w700, - fontSize: 16, + Expanded( + child: Text( + definition.name, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + fontSize: 16, + ), ), ), ], @@ -115,18 +274,22 @@ class _NavigationRail extends StatelessWidget { ), const Divider(height: 1, color: Color(0x337FFFFF)), const SizedBox(height: 12), - ..._AppShellState._pages.asMap().entries.map( - (entry) => _RailItem( - item: entry.value, - selected: selectedIndex == entry.key, - compact: compact, - onTap: () => onSelected(entry.key), + Expanded( + child: ListView.builder( + padding: EdgeInsets.zero, + itemCount: definition.pages.length, + itemBuilder: (context, index) => _RailItem( + item: definition.pages[index], + selected: selectedIndex == index, + compact: compact, + onTap: () => onSelected(index), + ), ), ), - const Spacer(), + const Divider(height: 1, color: Color(0x337FFFFF)), _RailItem( - item: const _PageInfo( - 'Einstellungen', + item: PageDefinition( + definition.settingsLabel, '', Icons.settings_outlined, '', @@ -134,7 +297,7 @@ class _NavigationRail extends StatelessWidget { compact: compact, onTap: () {}, ), - const SizedBox(height: 18), + const SizedBox(height: 16), ], ), ); @@ -148,7 +311,7 @@ class _RailItem extends StatelessWidget { this.selected = false, }); - final _PageInfo item; + final PageDefinition item; final bool compact; final bool selected; final VoidCallback onTap; @@ -179,14 +342,17 @@ class _RailItem extends StatelessWidget { Icon(item.icon, size: 21, color: foreground), if (!compact) const SizedBox(width: 13), if (!compact) - Text( - item.title, - style: TextStyle( - color: foreground, - fontWeight: selected - ? FontWeight.w700 - : FontWeight.w500, - fontSize: 14, + Expanded( + child: Text( + item.title, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: foreground, + fontWeight: selected + ? FontWeight.w700 + : FontWeight.w500, + fontSize: 14, + ), ), ), ], @@ -200,39 +366,31 @@ class _RailItem extends StatelessWidget { } class _TopBar extends StatelessWidget { - const _TopBar({required this.compact}); + const _TopBar({ + required this.definition, + required this.compact, + required this.onProductSelected, + }); + + final ProductDefinition definition; final bool compact; + final ValueChanged onProductSelected; + @override Widget build(BuildContext context) => Container( height: 74, - padding: const EdgeInsets.symmetric(horizontal: 32), + padding: EdgeInsets.symmetric(horizontal: compact ? 20 : 32), decoration: const BoxDecoration( color: AppColors.surface, border: Border(bottom: BorderSide(color: AppColors.line)), ), child: Row( children: [ - const Icon( - Icons.location_city_outlined, - color: AppColors.green, - size: 20, + _ProductSwitcher( + definition: definition, + compact: compact, + onSelected: onProductSelected, ), - const SizedBox(width: 10), - if (!compact) - const Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'TTC Musterstadt', - style: TextStyle(fontWeight: FontWeight.w700, fontSize: 14), - ), - Text( - 'Mein Verein', - style: TextStyle(color: AppColors.muted, fontSize: 12), - ), - ], - ), const Spacer(), IconButton( onPressed: () {}, @@ -243,9 +401,9 @@ class _TopBar extends StatelessWidget { ), ), const SizedBox(width: 10), - CircleAvatar( + const CircleAvatar( radius: 17, - backgroundColor: const Color(0xFFDDECE5), + backgroundColor: Color(0xFFDDECE5), child: Text( 'TM', style: TextStyle( @@ -260,9 +418,126 @@ class _TopBar extends StatelessWidget { ); } +class _ProductSwitcher extends StatelessWidget { + const _ProductSwitcher({ + required this.definition, + required this.compact, + required this.onSelected, + }); + + final ProductDefinition definition; + final bool compact; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) => PopupMenuButton( + tooltip: 'Produkt wechseln', + onSelected: onSelected, + itemBuilder: (context) => Product.values.map((product) { + final item = ProductDefinition.forProduct(product); + return PopupMenuItem( + value: product, + child: _ProductMenuItem( + definition: item, + active: product == definition.product, + ), + ); + }).toList(), + child: Semantics( + button: true, + label: 'Produktkontext: ${definition.name}. Produkt wechseln', + child: Container( + constraints: const BoxConstraints(maxWidth: 340), + padding: const EdgeInsets.fromLTRB(10, 8, 8, 8), + decoration: BoxDecoration( + border: Border.all(color: AppColors.line), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(definition.contextIcon, size: 20, color: AppColors.green), + if (!compact) const SizedBox(width: 9), + if (!compact) + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Produkt', + style: Theme.of(context).textTheme.bodySmall, + ), + Text( + definition.name, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + const SizedBox(width: 5), + const Icon( + Icons.unfold_more_rounded, + size: 19, + color: AppColors.muted, + ), + ], + ), + ), + ), + ); +} + +class _ProductMenuItem extends StatelessWidget { + const _ProductMenuItem({required this.definition, required this.active}); + final ProductDefinition definition; + final bool active; + + @override + Widget build(BuildContext context) => SizedBox( + width: 270, + child: Row( + children: [ + Icon( + definition.contextIcon, + color: active ? AppColors.green : AppColors.muted, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + definition.name, + style: TextStyle( + fontWeight: active ? FontWeight.w700 : FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + definition.contextLabel, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + if (active) + const Icon(Icons.check_rounded, size: 19, color: AppColors.green), + ], + ), + ); +} + class _Content extends StatelessWidget { - const _Content({required this.page}); - final _PageInfo page; + const _Content({required this.page, required this.product}); + final PageDefinition page; + final ProductDefinition product; + @override Widget build(BuildContext context) => SingleChildScrollView( padding: const EdgeInsets.fromLTRB(40, 38, 40, 40), @@ -271,6 +546,15 @@ class _Content extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text( + product.name, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppColors.green, + fontWeight: FontWeight.w700, + letterSpacing: .4, + ), + ), + const SizedBox(height: 6), Text(page.title, style: Theme.of(context).textTheme.displaySmall), const SizedBox(height: 8), Text( @@ -303,7 +587,7 @@ class _Content extends StatelessWidget { ), const SizedBox(height: 18), Text( - 'Bereit, wenn ihr es seid.', + 'Bereit, wenn du es bist.', style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 7), @@ -323,11 +607,3 @@ class _Content extends StatelessWidget { ), ); } - -class _PageInfo { - const _PageInfo(this.title, this.description, this.icon, this.emptyLabel); - final String title; - final String description; - final IconData icon; - final String emptyLabel; -} diff --git a/backend/desktop-app/test/widget_test.dart b/backend/desktop-app/test/widget_test.dart index 49a375f7..e5250689 100644 --- a/backend/desktop-app/test/widget_test.dart +++ b/backend/desktop-app/test/widget_test.dart @@ -5,8 +5,10 @@ // gestures. You can also use WidgetTester to find child widgets in the widget // tree, read text, and verify that the values of widget properties are correct. +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:tt_desktop/features/shell/presentation/app_shell.dart'; import 'package:tt_desktop/main.dart'; void main() { @@ -24,4 +26,21 @@ void main() { expect(find.text('Start'), findsWidgets); expect(find.text('Dein Überblick für heute.'), findsOneWidget); }); + + testWidgets('workspace switches to the club product', ( + WidgetTester tester, + ) async { + await tester.binding.setSurfaceSize(const Size(1280, 900)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget(const MaterialApp(home: AppShell())); + + await tester.tap(find.byTooltip('Produkt wechseln')); + await tester.pumpAndSettle(); + await tester.tap(find.text('TT Verein').last); + await tester.pumpAndSettle(); + + expect(find.text('TT Verein'), findsWidgets); + expect(find.text('Kommunikation'), findsWidgets); + expect(find.text('Alles Wichtige aus deinem Verein.'), findsOneWidget); + }); } diff --git a/docs/FLUTTER_DESKTOP_APP_PLAN.md b/docs/FLUTTER_DESKTOP_APP_PLAN.md index 10363b9d..48a36846 100644 --- a/docs/FLUTTER_DESKTOP_APP_PLAN.md +++ b/docs/FLUTTER_DESKTOP_APP_PLAN.md @@ -53,6 +53,7 @@ Bestehendes Node/Express-Backend - [x] Build-Konfigurationen `development`, `staging` und `production` mit `API_BASE_URL` per Dart-Define einrichten. - [x] App-Theme, Typografie, responsive Desktop-Layout und zentrale Design-Tokens anlegen. - [x] Navigation mit öffentlichem Bereich und geschütztem App-Bereich aufsetzen. +- [x] Produkt-Switch für Trainings-Tagebuch, TT Verein und Mein TT anlegen; Produktwechsel aktualisiert Kontext und Navigation. Rechtefilterung folgt mit Phase 2. - [x] Fehlerbehandlung, Logging ohne personenbezogene Daten und zentrale Ladeindikatoren implementieren. - [x] CI-Grundgerüst für Formatierung, Analyse und Tests anlegen. diff --git a/docs/FLUTTER_DESKTOP_MVP_SPEC.md b/docs/FLUTTER_DESKTOP_MVP_SPEC.md index 0d7a8fd1..738524e0 100644 --- a/docs/FLUTTER_DESKTOP_MVP_SPEC.md +++ b/docs/FLUTTER_DESKTOP_MVP_SPEC.md @@ -20,6 +20,19 @@ Die erste Version ist eine **online-first Flutter-App ohne WebView**. Sie verwen Der MVP liefert damit für alle Rollen eine verlässliche tägliche Übersicht. Schreibende Verwaltungs- und Trainerabläufe folgen erst, wenn die lesenden Kernansichten auf allen Zielplattformen stabil sind. +## 1.1 Produktwechsel in der Desktop-App + +Ein Benutzer kann innerhalb derselben Desktop-Installation zwischen den drei +Produkten **Trainings-Tagebuch** (`trainer`), **TT Verein** (`club`) und +**Mein TT** (`player`) wechseln. Der Wechsel ändert Produktname, Navigation, +Startbereich und Kontext sofort; Datenbank, Benutzerkonto und API bleiben +gemeinsam. + +Der Switch ist kein Berechtigungsmechanismus. Ab Phase 2 werden die sichtbaren +Produkte und Menüpunkte zusätzlich aus Vereinszugang und den geladenen +Berechtigungen gefiltert. Produkte ohne Zugriff dürfen dann nicht auswählbar +sein. + ## 2. Rollen- und Sichtbarkeitsmatrix Die Berechtigungen kommen ausschließlich vom Backend (`GET /api/permissions/:clubId`). Die folgende Matrix definiert nur die Desktop-Navigation; sie ersetzt niemals eine Serverprüfung.