Feature: Refactor _DesktopDrawer to support expandable/collapsible states, introduce glass effect panel design, and refine navigation UI.
This commit is contained in:
@@ -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,37 +289,96 @@ 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: ListView(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
padding: const EdgeInsets.all(12),
|
||||||
children: [
|
child: ClipRRect(
|
||||||
// const Divider(),
|
borderRadius: BorderRadius.circular(20),
|
||||||
for (var i = 0; i < visibleItems.length; i++)
|
child: BackdropFilter(
|
||||||
ListTile(
|
filter: ImageFilter.blur(sigmaX: 14, sigmaY: 14),
|
||||||
selected: i == safeSelected,
|
child: AnimatedContainer(
|
||||||
leading: Icon(
|
duration: const Duration(milliseconds: 220),
|
||||||
i == safeSelected
|
curve: Curves.easeOutCubic,
|
||||||
? (visibleItems[i].selectedIcon ??
|
width: targetWidth,
|
||||||
visibleItems[i].icon)
|
decoration: BoxDecoration(
|
||||||
: visibleItems[i].icon,
|
color: panelColor,
|
||||||
),
|
borderRadius: BorderRadius.circular(20),
|
||||||
title: Text(visibleItems[i].label),
|
border: Border.all(
|
||||||
onTap: () => context.go(visibleItems[i].route),
|
color: cs.outlineVariant.withOpacity(0.40),
|
||||||
),
|
),
|
||||||
// Optional: Settings als fixer Eintrag,
|
boxShadow: [
|
||||||
// falls du ihn unabhängig von items immer unten haben willst:
|
BoxShadow(
|
||||||
// const Divider(),
|
color: cs.shadow.withOpacity(0.04),
|
||||||
// ListTile(
|
blurRadius: 18,
|
||||||
// leading: const Icon(Icons.settings_outlined),
|
offset: const Offset(0, 8),
|
||||||
// title: Text(t.app.navigationSettings),
|
),
|
||||||
// onTap: () => context.go(AppRoute.settings.path),
|
],
|
||||||
// ),
|
),
|
||||||
],
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final bool narrow = constraints.maxWidth < _switchAtWidth;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
// --------- CONTENT ----------
|
||||||
|
Expanded(
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
children: [
|
||||||
|
for (var i = 0; i < visibleItems.length; i++)
|
||||||
|
narrow
|
||||||
|
? _CollapsedIconItem(
|
||||||
|
icon:
|
||||||
|
visibleItems[i].selectedIcon ??
|
||||||
|
visibleItems[i].icon,
|
||||||
|
tooltip: visibleItems[i].label,
|
||||||
|
selected: i == safeSelected,
|
||||||
|
onTap: () =>
|
||||||
|
context.go(visibleItems[i].route),
|
||||||
|
)
|
||||||
|
: _ExpandedDrawerItem(
|
||||||
|
icon: visibleItems[i].icon,
|
||||||
|
selectedIcon:
|
||||||
|
visibleItems[i].selectedIcon ??
|
||||||
|
visibleItems[i].icon,
|
||||||
|
label: visibleItems[i].label,
|
||||||
|
selected: i == safeSelected,
|
||||||
|
onTap: () =>
|
||||||
|
context.go(visibleItems[i].route),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -322,3 +387,103 @@ class _DesktopDrawer extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user