feat: Implement product switcher for AppShell and update navigation context
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 53s
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 53s
This commit is contained in:
@@ -5,61 +5,36 @@ import '../../../core/widgets/product_mark.dart';
|
||||
|
||||
class AppShell extends StatefulWidget {
|
||||
const AppShell({super.key});
|
||||
|
||||
@override
|
||||
State<AppShell> createState() => _AppShellState();
|
||||
}
|
||||
|
||||
class _AppShellState extends State<AppShell> {
|
||||
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<AppShell> {
|
||||
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<AppShell> {
|
||||
}
|
||||
}
|
||||
|
||||
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<PageDefinition> 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<int> 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<Product> 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<Product> onSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => PopupMenuButton<Product>(
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user