Files
finlog/finlog_app/app/lib/modules/app_shell.dart

345 lines
10 KiB
Dart

import 'package:app/core/app/router.dart';
import 'package:app/core/features/feature_controller.dart';
import 'package:app/core/i18n/translations.g.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
class AppShell extends StatefulWidget {
final Widget child;
const AppShell({super.key, required this.child});
@override
State<AppShell> createState() => _AppShellState();
}
class _AppShellState extends State<AppShell> {
static const double _breakpoint = 800;
final FocusNode _contentFocus = FocusNode(debugLabel: 'contentFocus');
@override
void dispose() {
_contentFocus.dispose();
super.dispose();
}
// --- NAV ITEMS -------------------------------------------------------------
// Desktop/Tablet drawer items
List<({IconData icon, IconData? selectedIcon, String label, String route})>
_getDesktopItems(BuildContext context) {
final t = Translations.of(context);
return [
(
icon: Icons.dashboard_outlined,
selectedIcon: Icons.dashboard,
label: t.app.navigationDashboard,
route: AppRoute.home.path,
),
(
icon: Icons.account_balance_wallet_outlined,
selectedIcon: Icons.account_balance_wallet,
label: t.app.navigationBudgets, // Haushaltsbereich inkl. Budget
route: AppRoute.budget.path,
),
(
icon: Icons.inventory_2_outlined,
selectedIcon: Icons.inventory_2,
label: t.app.navigationInventory,
route: AppRoute.inventory.path,
),
(
icon: Icons.bar_chart_outlined,
selectedIcon: Icons.bar_chart,
label: t.app.navigationReports,
route: AppRoute.reports.path,
),
(
icon: Icons.settings_outlined,
selectedIcon: Icons.settings,
label: t.app.navigationSettings,
route: AppRoute.settings.path,
),
];
}
// Mobile bottom bar items (4 Tabs)
List<({IconData icon, String label, String route})> _getMobileTabs(
BuildContext context,
) {
final t = Translations.of(context);
return [
(
icon: Icons.dashboard,
label: t.app.navigationDashboard,
route: AppRoute.home.path,
),
(
icon: Icons.home,
label: (t.app.navigationHousehold),
route: AppRoute.budget.path,
),
(
icon: Icons.inventory_2,
label: t.app.navigationInventory,
route: AppRoute.inventory.path,
),
(
icon: Icons.directions_car,
label: (t.app.navigationCar),
route: AppRoute.car.path,
),
];
}
int _indexForPath<T>(String path, List<T> items, String Function(T) routeOf) {
for (var i = 0; i < items.length; i++) {
if (path.startsWith(routeOf(items[i]))) return i;
}
return 0;
}
// Route→Feature-Guard
bool _routeEnabled(String route, FeatureController fc) {
if (route.startsWith(AppRoute.home.path)) return true;
if (route.startsWith(AppRoute.settings.path)) return true;
if (route.startsWith(AppRoute.budget.path)) return fc.hasHousehold;
if (route.startsWith(AppRoute.inventory.path)) return fc.hasInventory;
if (route.startsWith(AppRoute.car.path)) return fc.hasCar;
if (route.startsWith(AppRoute.reports.path)) return fc.hasReports;
// Default: sichtbar
return true;
}
@override
Widget build(BuildContext context) {
final t = Translations.of(context);
final width = MediaQuery.of(context).size.width;
final isDesktop = width >= _breakpoint;
final currentPath = GoRouterState.of(context).matchedLocation;
// keep focus on right/content pane on wide layouts
if (isDesktop) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && !_contentFocus.hasFocus) _contentFocus.requestFocus();
});
}
final appBar = AppBar(
title: const _LogoHeader(),
// Kein Burger-Button, da mobil ohne Drawer (BottomNav) und Desktop mit persistentem Drawer
leading: null,
actions: [
IconButton(
tooltip: t.app.tooltipNotifications,
onPressed: () {},
icon: const Icon(Icons.notifications_none),
),
IconButton(
tooltip: t.app.tooltipUserSettings,
onPressed: () => context.push(AppRoute.settings.path),
icon: const Icon(Icons.account_circle_outlined),
),
],
);
if (!isDesktop) {
// ------------------- MOBILE: Bottom Navigation -------------------
final fc = context.read<FeatureController>();
// Wenn aktuelle Route deaktiviert ist, sanft nach Home umleiten
WidgetsBinding.instance.addPostFrameCallback((_) {
final p = GoRouterState.of(context).matchedLocation;
if (!_routeEnabled(p, fc)) {
if (mounted) context.go(AppRoute.home.path);
}
});
return Scaffold(
appBar: appBar,
body: SafeArea(child: widget.child),
bottomNavigationBar: AnimatedBuilder(
animation: fc,
builder: (context, _) {
final baseTabs = _getMobileTabs(context);
// Erlaubte Tabs filtern
var tabs = <({IconData icon, String label, String route})>[
for (final it in baseTabs)
if (_routeEnabled(it.route, fc)) it,
];
// Fallback: min. 2 Ziele sicherstellen
if (tabs.length < 2) {
// Home ist immer erlaubt; „Einstellungen“ als zweites Ziel ergänzen
tabs = [
// Stelle sicher, dass Home als erstes drin ist
(
icon: Icons.dashboard,
label: Translations.of(context).app.navigationDashboard,
route: AppRoute.home.path,
),
(
icon: Icons.settings,
label: Translations.of(context).app.navigationSettings,
route: AppRoute.settings.path,
),
];
}
// aktuellen Index robust bestimmen
final currentPath = GoRouterState.of(context).matchedLocation;
int selected = 0;
for (var i = 0; i < tabs.length; i++) {
if (currentPath.startsWith(tabs[i].route)) {
selected = i;
break;
}
}
return NavigationBar(
selectedIndex: selected,
onDestinationSelected: (i) => context.go(tabs[i].route),
destinations: [
for (final it in tabs)
NavigationDestination(icon: Icon(it.icon), label: it.label),
],
);
},
),
);
}
// ------------------- TABLET/DESKTOP: Persistent Drawer -------------------
final items = _getDesktopItems(context);
final selected = _indexForPath(currentPath, items, (it) => it.route);
return Scaffold(
appBar: appBar,
body: Row(
children: [
// Persistent drawer area
SizedBox(
width: 300,
child: _DesktopDrawer(items: items, selectedIndex: selected),
),
const VerticalDivider(width: 1),
// Content
Expanded(
child: SafeArea(
child: Focus(
focusNode: _contentFocus,
autofocus: true,
child: widget.child,
),
),
),
],
),
);
}
}
class _DesktopDrawer extends StatelessWidget {
final List<
({IconData icon, IconData? selectedIcon, String label, String route})
>
items;
final int selectedIndex;
const _DesktopDrawer({required this.items, required this.selectedIndex});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final fc = context.read<FeatureController>();
bool routeEnabled(String route) {
if (route.startsWith(AppRoute.home.path)) return true;
if (route.startsWith(AppRoute.settings.path)) return true;
if (route.startsWith(AppRoute.budget.path)) return fc.hasHousehold;
if (route.startsWith(AppRoute.inventory.path)) return fc.hasInventory;
if (route.startsWith(AppRoute.car.path)) return fc.hasCar;
if (route.startsWith(AppRoute.reports.path)) return fc.hasReports;
return true;
}
return AnimatedBuilder(
animation: fc,
builder: (context, _) {
final visibleItems = [
for (final it in items)
if (routeEnabled(it.route)) it,
];
final currentPath = GoRouterState.of(context).matchedLocation;
int safeSelected = 0;
for (var i = 0; i < visibleItems.length; i++) {
if (currentPath.startsWith(visibleItems[i].route)) {
safeSelected = i;
break;
}
}
return Material(
elevation: 0,
child: SafeArea(
child: ListTileTheme(
selectedColor: scheme.onSecondaryContainer,
selectedTileColor: scheme.secondaryContainer,
child: ListView(
padding: const EdgeInsets.symmetric(vertical: 8),
children: [
// const Divider(),
for (var i = 0; i < visibleItems.length; i++)
ListTile(
selected: i == safeSelected,
leading: Icon(
i == safeSelected
? (visibleItems[i].selectedIcon ??
visibleItems[i].icon)
: visibleItems[i].icon,
),
title: Text(visibleItems[i].label),
onTap: () => context.go(visibleItems[i].route),
),
// Optional: Settings als fixer Eintrag,
// falls du ihn unabhängig von items immer unten haben willst:
// const Divider(),
// ListTile(
// leading: const Icon(Icons.settings_outlined),
// title: Text(t.app.navigationSettings),
// onTap: () => context.go(AppRoute.settings.path),
// ),
],
),
),
),
);
},
);
}
}
class _LogoHeader extends StatelessWidget {
const _LogoHeader();
@override
Widget build(BuildContext context) {
return const Padding(
padding: EdgeInsets.all(16),
child: Row(
children: [
Icon(Icons.account_balance_wallet, size: 28),
SizedBox(width: 8),
],
),
);
}
}