feat: Implement product switcher for AppShell and update navigation context
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 53s

This commit is contained in:
Torsten Schulz (local)
2026-07-30 10:37:39 +02:00
parent b404c289bb
commit 240cbf7d14
4 changed files with 413 additions and 104 deletions

View File

@@ -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;
}

View File

@@ -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);
});
}