Feature: Add feature toggles and settings for modular features (e.g., Car, Inventory), enhance navigation for mobile/desktop, and improve i18n integration.

This commit is contained in:
2025-09-27 13:37:43 +02:00
parent 8ca98d4720
commit 8fa071e565
13 changed files with 545 additions and 81 deletions

View File

@@ -12,8 +12,7 @@ class AppShell extends StatefulWidget {
}
class _AppShellState extends State<AppShell> {
static const double _railBreakpoint = 800; // tablet and up
bool _railExtended = true; // start "open" on tablet/desktop
static const double _breakpoint = 800; // Tablet/Desktop threshold
final FocusNode _contentFocus = FocusNode(debugLabel: 'contentFocus');
@override
@@ -24,14 +23,16 @@ class _AppShellState extends State<AppShell> {
// --- NAV ITEMS -------------------------------------------------------------
List<({IconData icon, IconData? selectedIcon, String label, String route})> _getItems(BuildContext context) {
// Desktop/Tablet drawer items (you can keep them rich/longer here)
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: '/home',
route: '/',
),
(
icon: Icons.account_balance_wallet_outlined,
@@ -52,41 +53,62 @@ class _AppShellState extends State<AppShell> {
route: '/reports',
),
(
icon: Icons.settings,
selectedIcon: Icons.settings_outlined,
icon: Icons.settings_outlined,
selectedIcon: Icons.settings,
label: t.app.navigationSettings,
route: '/settings',
),
];
}
int _indexForPath(String p, List<({IconData icon, IconData? selectedIcon, String label, String route})> items) {
// Mobile bottom bar items (exactly the four you asked for)
List<({IconData icon, String label, String route})> _getMobileTabs(
BuildContext context,
) {
final t = Translations.of(context);
return [
(icon: Icons.dashboard, label: t.app.navigationDashboard, route: '/home'),
// “Haushalt (inkl. Budget)” → map to /budget for now
(icon: Icons.home, label: 'Haushalt', route: '/budget'),
(icon: Icons.inventory_2, label: 'Inventar', route: '/inventory'),
(icon: Icons.directions_car, label: 'Auto', route: '/car'),
];
}
int _indexForPath<T>(String path, List<T> items, String Function(T) routeOf) {
for (var i = 0; i < items.length; i++) {
if (p.startsWith(items[i].route)) return i;
if (path.startsWith(routeOf(items[i]))) return i;
}
return 0;
}
void _goForIndex(BuildContext ctx, int i, List<({IconData icon, IconData? selectedIcon, String label, String route})> items) => ctx.go(items[i].route);
void _goForIndex<T>(
BuildContext ctx,
int i,
List<T> items,
String Function(T) routeOf,
) {
ctx.go(routeOf(items[i]));
}
@override
Widget build(BuildContext context) {
final t = Translations.of(context);
final items = _getItems(context);
final width = MediaQuery.of(context).size.width;
final isRail = width >= _railBreakpoint;
final isDesktop = width >= _breakpoint;
final currentPath = GoRouterState.of(context).matchedLocation;
// keep focus on right/content pane on wide layouts
if (isRail) {
if (isDesktop) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && !_contentFocus.hasFocus) _contentFocus.requestFocus();
});
}
final appBar = AppBar(
title: _LogoHeader(),
leading: isRail
title: const _LogoHeader(),
// On desktop we use a persistent drawer, so no burger button.
leading: isDesktop
? null
: Builder(
builder: (ctx) => IconButton(
@@ -109,79 +131,42 @@ class _AppShellState extends State<AppShell> {
],
);
if (!isRail) {
// ------------------- MOBILE: Drawer -------------------
final selectedIndex = _indexForPath(currentPath, items);
if (!isDesktop) {
// ------------------- MOBILE: Bottom Navigation -------------------
final tabs = _getMobileTabs(context);
final selected = _indexForPath(currentPath, tabs, (it) => it.route);
return Scaffold(
appBar: appBar,
drawer: _AppDrawer(items: items, selectedIndex: selectedIndex),
// Keep drawer for mobile? You asked for bottom bar instead — remove drawer.
body: SafeArea(child: widget.child),
bottomNavigationBar: NavigationBar(
selectedIndex: selected,
onDestinationSelected: (i) =>
_goForIndex(context, i, tabs, (it) => it.route),
destinations: [
for (final it in tabs)
NavigationDestination(icon: Icon(it.icon), label: it.label),
],
),
);
}
// ------------------- TABLET/DESKTOP: NavigationRail -------------------
final selected = _indexForPath(currentPath, items);
final scheme = Theme.of(context).colorScheme;
// ------------------- TABLET/DESKTOP: Persistent Drawer -------------------
final items = _getDesktopItems(context);
final selected = _indexForPath(currentPath, items, (it) => it.route);
return Scaffold(
appBar: appBar,
body: Row(
children: [
NavigationRailTheme(
data: NavigationRailThemeData(
groupAlignment: -1.0,
// align to top
useIndicator: true,
indicatorColor: scheme.secondaryContainer,
// background for selected "button"
indicatorShape: const StadiumBorder(),
selectedIconTheme: IconThemeData(
color: scheme.onSecondaryContainer,
),
selectedLabelTextStyle: TextStyle(
color: scheme.onSecondaryContainer,
fontWeight: FontWeight.w600,
),
),
child: NavigationRail(
extended: _railExtended,
selectedIndex: selected,
onDestinationSelected: (i) => _goForIndex(context, i, items),
// leading: const Padding(
// padding: EdgeInsets.only(top: 8),
// child: _LogoHeader(),
// ),
destinations: [
for (final it in items)
NavigationRailDestination(
icon: Icon(it.icon),
selectedIcon: Icon(it.selectedIcon ?? it.icon),
label: Text(it.label),
),
],
trailingAtBottom: true,
trailing: Column(
children: [
const SizedBox(height: 8),
const Divider(height: 1),
// toggle lives at the very bottom so layout doesn't jump
IconButton(
tooltip: _railExtended
? t.app.tooltipCollapseRail
: t.app.tooltipExpandRail,
onPressed: () =>
setState(() => _railExtended = !_railExtended),
icon: Icon(
_railExtended
? Icons.keyboard_double_arrow_left
: Icons.keyboard_double_arrow_right,
),
),
],
),
),
// Persistent drawer area
SizedBox(
width: 300,
child: _DesktopDrawer(items: items, selectedIndex: selected),
),
const VerticalDivider(width: 1),
// Content
Expanded(
child: SafeArea(
child: Focus(
@@ -197,6 +182,49 @@ class _AppShellState extends State<AppShell> {
}
}
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;
return Material(
elevation: 0,
child: SafeArea(
child: ListTileTheme(
selectedColor: scheme.onSecondaryContainer,
selectedTileColor: scheme.secondaryContainer,
child: ListView(
padding: const EdgeInsets.symmetric(vertical: 8),
children: [
const _LogoHeader(),
const Divider(),
for (var i = 0; i < items.length; i++)
ListTile(
selected: i == selectedIndex,
leading: Icon(
i == selectedIndex
? (items[i].selectedIcon ?? items[i].icon)
: items[i].icon,
),
title: Text(items[i].label),
onTap: () => context.go(items[i].route),
),
],
),
),
),
);
}
}
class _AppDrawer extends StatelessWidget {
final List<
({IconData icon, IconData? selectedIcon, String label, String route})

View File

@@ -0,0 +1,10 @@
import 'package:flutter/material.dart';
class CarView extends StatelessWidget {
const CarView({super.key});
@override
Widget build(BuildContext context) {
return const Center(child: Text('Auto-Manager '));
}
}

View File

@@ -0,0 +1,73 @@
import 'package:app/core/features/feature_controller.dart';
import 'package:app/modules/settings/modules/app/model/feature_settings_view_model.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:app/core/i18n/translations.g.dart';
/// A dedicated section under "Einstellungen" to enable/disable features.
/// You can link this screen from your existing Settings list.
/// If you keep a single Settings page, render _FeatureSettingsSection in-place.
class FeatureSettingsView extends StatelessWidget {
const FeatureSettingsView({super.key});
@override
Widget build(BuildContext context) {
final t = Translations.of(context);
final featureController = context.read<FeatureController>();
final model = FeatureSettingsViewModel(featureController);
return ChangeNotifierProvider(
create: (BuildContext context) => model,
child: Scaffold(
appBar: AppBar(title: Text(t.settings.featureSettings)),
body: const _FeatureSettingsSection(),
),
);
}
}
/// If you prefer to embed this into your existing AppSettingsView,
/// use this widget directly inside your Settings ListView/CustomScrollView.
class _FeatureSettingsSection extends StatelessWidget {
const _FeatureSettingsSection();
@override
Widget build(BuildContext context) {
final controller = context.watch<FeatureController>();
final states = controller.allStates;
return ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: states.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (ctx, i) {
final feature = states.keys.elementAt(i);
final enabled = states[feature] ?? feature.defaultEnabled;
final icon = _iconFor(feature);
return SwitchListTile(
value: enabled,
secondary: Icon(icon),
title: Text(feature.displayName(context)),
subtitle: Text(feature.description(context)),
onChanged: (v) => controller.setEnabled(feature, v),
);
},
);
}
IconData _iconFor(AppFeature f) {
switch (f) {
case AppFeature.inventory:
return Icons.inventory_2_outlined;
case AppFeature.car:
return Icons.directions_car;
case AppFeature.household:
return Icons.home_outlined;
case AppFeature.reports:
return Icons.bar_chart_outlined;
}
}
}

View File

@@ -0,0 +1,22 @@
import 'package:app/core/features/feature_controller.dart';
import 'package:flutter/foundation.dart';
/// Lightweight VM that wraps FeatureController for the Settings screen.
/// Mirrors your other *ViewModel classes init pattern.
class FeatureSettingsViewModel extends ChangeNotifier {
FeatureSettingsViewModel(this._featureController);
final FeatureController _featureController;
Map<AppFeature, bool> get states => _featureController.allStates;
bool isEnabled(AppFeature f) => _featureController.isEnabled(f);
Future<void> toggle(AppFeature f, bool value) async {
await _featureController.setEnabled(f, value);
notifyListeners();
}
/// Expose the controller to listen from the view if needed
FeatureController get controller => _featureController;
}

View File

@@ -1,6 +1,7 @@
import 'package:app/core/i18n/translations.g.dart';
import 'package:app/core/ui/panel.dart';
import 'package:app/modules/settings/modules/app/app_settings_view.dart';
import 'package:app/modules/settings/modules/app/features_settings_view.dart';
import 'package:app/modules/settings/modules/help/feedback_view.dart';
import 'package:app/modules/settings/modules/help/help_view.dart';
import 'package:app/modules/settings/modules/help/legal_view.dart';
@@ -16,7 +17,7 @@ class SettingsView extends StatelessWidget {
final t = Translations.of(context);
return Scaffold(
appBar: AppBar(title: Text(t.settings.title)),
// appBar: AppBar(title: Text(t.settings.title)),
body: PanelNavigator(rootBuilder: (ctx) => _CategoryList()),
);
}
@@ -64,6 +65,12 @@ class _CategoryList extends StatelessWidget {
() => const AppSettingsView(),
),
const SizedBox(height: 12),
tile(
Icons.tune,
t.settings.featureSettings,
() => const FeatureSettingsView(),
),
const SizedBox(height: 12),
_SectionHeader(t.settings.sections.account),
tile(