Feature: Refactor _DesktopDrawer to support expandable/collapsible states, introduce glass effect panel design, and refine navigation UI.

This commit is contained in:
2025-09-28 13:14:47 +02:00
parent 6532add0d6
commit e8132cee01

View File

@@ -1,3 +1,5 @@
import 'dart:ui' show ImageFilter;
import 'package:app/core/app/router.dart'; import 'package:app/core/app/router.dart';
import 'package:app/core/app/features/feature_controller.dart'; import 'package:app/core/app/features/feature_controller.dart';
import 'package:app/core/i18n/translations.g.dart'; import 'package:app/core/i18n/translations.g.dart';
@@ -130,8 +132,6 @@ class _AppShellState extends State<AppShell> {
} }
final appBar = AppBar( final appBar = AppBar(
// title: const _LogoHeader(),
// Kein Burger-Button, da mobil ohne Drawer (BottomNav) und Desktop mit persistentem Drawer
leading: null, leading: null,
actions: [ actions: [
IconButton( IconButton(
@@ -218,11 +218,7 @@ class _AppShellState extends State<AppShell> {
appBar: appBar, appBar: appBar,
body: Row( body: Row(
children: [ children: [
// Persistent drawer area _DesktopDrawer(items: items, selectedIndex: selected),
SizedBox(
width: 300,
child: _DesktopDrawer(items: items, selectedIndex: selected),
),
const VerticalDivider(width: 1), const VerticalDivider(width: 1),
// Content // Content
Expanded( Expanded(
@@ -240,7 +236,7 @@ class _AppShellState extends State<AppShell> {
} }
} }
class _DesktopDrawer extends StatelessWidget { class _DesktopDrawer extends StatefulWidget {
final List< final List<
({IconData icon, IconData? selectedIcon, String label, String route}) ({IconData icon, IconData? selectedIcon, String label, String route})
> >
@@ -249,20 +245,30 @@ class _DesktopDrawer extends StatelessWidget {
const _DesktopDrawer({required this.items, required this.selectedIndex}); const _DesktopDrawer({required this.items, required this.selectedIndex});
@override
State<_DesktopDrawer> createState() => _DesktopDrawerState();
}
class _DesktopDrawerState extends State<_DesktopDrawer> {
bool _expanded = true; // start expanded
// You can tweak this threshold if needed.
static const double _collapseWidth = 88.0; // collapsed width
static const double _expandWidth = 280.0; // expanded width
static const double _switchAtWidth = 160.0; // below this, use icon-only mode
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme; final cs = Theme.of(context).colorScheme;
final fc = context.read<FeatureController>(); final fc = context.read<FeatureController>();
bool routeEnabled(String route) { bool routeEnabled(String route) {
if (route.startsWith(AppRoute.home.path)) return true; if (route.startsWith(AppRoute.home.path)) return true;
if (route.startsWith(AppRoute.settings.path)) return true; if (route.startsWith(AppRoute.settings.path)) return true;
if (route.startsWith(AppRoute.budget.path)) return fc.hasHousehold; if (route.startsWith(AppRoute.budget.path)) return fc.hasHousehold;
if (route.startsWith(AppRoute.inventory.path)) return fc.hasInventory; if (route.startsWith(AppRoute.inventory.path)) return fc.hasInventory;
if (route.startsWith(AppRoute.car.path)) return fc.hasCar; if (route.startsWith(AppRoute.car.path)) return fc.hasCar;
if (route.startsWith(AppRoute.reports.path)) return fc.hasReports; if (route.startsWith(AppRoute.reports.path)) return fc.hasReports;
return true; return true;
} }
@@ -270,7 +276,7 @@ class _DesktopDrawer extends StatelessWidget {
animation: fc, animation: fc,
builder: (context, _) { builder: (context, _) {
final visibleItems = [ final visibleItems = [
for (final it in items) for (final it in widget.items)
if (routeEnabled(it.route)) it, if (routeEnabled(it.route)) it,
]; ];
@@ -283,42 +289,201 @@ class _DesktopDrawer extends StatelessWidget {
} }
} }
return Material( final double targetWidth = _expanded ? _expandWidth : _collapseWidth;
elevation: 0,
child: SafeArea( // Use the same tint as mobile glass bar: cs.surface.withOpacity(0.65)
child: ListTileTheme( final Color panelColor = cs.surface.withOpacity(0.65);
selectedColor: scheme.onSecondaryContainer,
selectedTileColor: scheme.secondaryContainer, return SafeArea(
child: Padding(
padding: const EdgeInsets.all(12),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 14, sigmaY: 14),
child: AnimatedContainer(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
width: targetWidth,
decoration: BoxDecoration(
color: panelColor,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: cs.outlineVariant.withOpacity(0.40),
),
boxShadow: [
BoxShadow(
color: cs.shadow.withOpacity(0.04),
blurRadius: 18,
offset: const Offset(0, 8),
),
],
),
child: LayoutBuilder(
builder: (context, constraints) {
final bool narrow = constraints.maxWidth < _switchAtWidth;
return Column(
children: [
// --------- CONTENT ----------
Expanded(
child: ListView( child: ListView(
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8),
children: [ children: [
// const Divider(),
for (var i = 0; i < visibleItems.length; i++) for (var i = 0; i < visibleItems.length; i++)
ListTile( narrow
? _CollapsedIconItem(
icon:
visibleItems[i].selectedIcon ??
visibleItems[i].icon,
tooltip: visibleItems[i].label,
selected: i == safeSelected, selected: i == safeSelected,
leading: Icon( onTap: () =>
i == safeSelected context.go(visibleItems[i].route),
? (visibleItems[i].selectedIcon ?? )
visibleItems[i].icon) : _ExpandedDrawerItem(
: visibleItems[i].icon, icon: visibleItems[i].icon,
selectedIcon:
visibleItems[i].selectedIcon ??
visibleItems[i].icon,
label: visibleItems[i].label,
selected: i == safeSelected,
onTap: () =>
context.go(visibleItems[i].route),
), ),
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),
// ),
], ],
), ),
), ),
const Divider(height: 1),
// Expand/Collapse button (same behavior as before)
IconButton(
tooltip: _expanded
? Translations.of(
context,
).app.tooltipCollapseRail
: Translations.of(
context,
).app.tooltipExpandRail,
onPressed: () =>
setState(() => _expanded = !_expanded),
icon: Icon(
_expanded
? Icons.keyboard_double_arrow_left
: Icons.keyboard_double_arrow_right,
),
),
],
);
},
),
),
),
),
), ),
); );
}, },
); );
} }
} }
class _ExpandedDrawerItem extends StatelessWidget {
const _ExpandedDrawerItem({
required this.icon,
required this.selectedIcon,
required this.label,
required this.selected,
required this.onTap,
});
final IconData icon;
final IconData selectedIcon;
final String label;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: selected
? cs.primaryContainer.withOpacity(0.35)
: Colors.transparent,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
selected ? selectedIcon : icon,
size: 22,
color: selected ? cs.onPrimaryContainer : cs.onSurfaceVariant,
),
const SizedBox(width: 16),
Expanded(
child: Text(
label,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: selected ? cs.onPrimaryContainer : cs.onSurfaceVariant,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
],
),
),
);
}
}
class _CollapsedIconItem extends StatelessWidget {
const _CollapsedIconItem({
required this.icon,
required this.tooltip,
required this.selected,
required this.onTap,
});
final IconData icon;
final String tooltip;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Tooltip(
message: tooltip,
waitDuration: const Duration(milliseconds: 400),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: selected
? cs.primaryContainer.withOpacity(0.35)
: Colors.transparent,
borderRadius: BorderRadius.circular(12),
),
alignment: Alignment.center,
child: Icon(
icon,
size: 22,
color: selected ? cs.onPrimaryContainer : cs.onSurfaceVariant,
),
),
),
);
}
}