feat: sidebar accordion groups + mobile nav + AI FAB
This commit is contained in:
+378
-42
@@ -165,6 +165,41 @@ class _NavItem {
|
|||||||
final bool Function(TenantMembership?) visible;
|
final bool Function(TenantMembership?) visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Nav group (dropdown in sidebar) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
class _NavGroup {
|
||||||
|
const _NavGroup({
|
||||||
|
required this.title,
|
||||||
|
required this.icon,
|
||||||
|
required this.selectedIcon,
|
||||||
|
required this.items,
|
||||||
|
});
|
||||||
|
final String title;
|
||||||
|
final IconData icon;
|
||||||
|
final IconData selectedIcon;
|
||||||
|
final List<_NavItem> items;
|
||||||
|
|
||||||
|
/// Whether any item in this group is visible for the given membership
|
||||||
|
bool hasVisible(TenantMembership? m) => items.any((it) => it.visible(m));
|
||||||
|
|
||||||
|
/// Whether any item in this group is currently selected
|
||||||
|
bool containsRoute(String route) => items.any((it) => it.route == route);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sidebar entry (single item or group) ─────────────────────────────────────
|
||||||
|
|
||||||
|
sealed class _SidebarEntry {}
|
||||||
|
|
||||||
|
class _SidebarSingleEntry extends _SidebarEntry {
|
||||||
|
final _NavItem item;
|
||||||
|
_SidebarSingleEntry(this.item);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SidebarGroupEntry extends _SidebarEntry {
|
||||||
|
final _NavGroup group;
|
||||||
|
_SidebarGroupEntry(this.group);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Clinic shell ──────────────────────────────────────────────────────────────
|
// ── Clinic shell ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class _ClinicShell extends ConsumerStatefulWidget {
|
class _ClinicShell extends ConsumerStatefulWidget {
|
||||||
@@ -176,9 +211,37 @@ class _ClinicShell extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ClinicShellState extends ConsumerState<_ClinicShell> {
|
class _ClinicShellState extends ConsumerState<_ClinicShell> {
|
||||||
int _index = 0;
|
String _selectedRoute = routeClinicDashboard;
|
||||||
|
|
||||||
static final _allItems = [
|
// Top-level singles before groups
|
||||||
|
static final _topSingles = [
|
||||||
|
_NavItem(route: routeClinicDashboard, icon: const Icon(Icons.home_outlined), selectedIcon: const Icon(Icons.home_rounded), label: 'Ana Sayfa', visible: (_) => true),
|
||||||
|
_NavItem(route: routeClinicJobs, icon: const Icon(Icons.work_outline_rounded), selectedIcon: const Icon(Icons.work_rounded), label: 'İşler', visible: (m) => m?.showJobs ?? true),
|
||||||
|
_NavItem(route: routeClinicPatients, icon: const Icon(Icons.people_outline_rounded), selectedIcon: const Icon(Icons.people_rounded), label: 'Hastalar', visible: (m) => m?.showPatients ?? true),
|
||||||
|
_NavItem(route: routeClinicFinance, icon: const Icon(Icons.account_balance_outlined), selectedIcon: const Icon(Icons.account_balance_rounded), label: 'Finans', visible: (m) => m?.showFinance ?? true),
|
||||||
|
_NavItem(route: routeClinicAi, icon: const Icon(Icons.auto_awesome_outlined), selectedIcon: const Icon(Icons.auto_awesome_rounded), label: 'AI Sohbet', visible: (_) => true),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Dropdown groups
|
||||||
|
static final _groups = [
|
||||||
|
_NavGroup(
|
||||||
|
title: 'Yönetim',
|
||||||
|
icon: Icons.tune_rounded,
|
||||||
|
selectedIcon: Icons.tune_rounded,
|
||||||
|
items: [
|
||||||
|
_NavItem(route: routeClinicConnections, icon: const Icon(Icons.link_rounded), selectedIcon: const Icon(Icons.link_rounded), label: 'Bağlantılar', visible: (_) => true),
|
||||||
|
_NavItem(route: routeClinicReports, icon: const Icon(Icons.bar_chart_outlined), selectedIcon: const Icon(Icons.bar_chart_rounded), label: 'Raporlar', visible: (_) => true),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Singles after groups
|
||||||
|
static final _bottomSingles = [
|
||||||
|
_NavItem(route: routeClinicSettings, icon: const Icon(Icons.settings_outlined), selectedIcon: const Icon(Icons.settings_rounded), label: 'Ayarlar', visible: (_) => true),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mobile bottom nav: core items; others accessed from settings
|
||||||
|
static final _mobileItems = [
|
||||||
_NavItem(route: routeClinicDashboard, icon: const Icon(Icons.home_outlined), selectedIcon: const Icon(Icons.home_rounded), label: 'Ana Sayfa', visible: (_) => true),
|
_NavItem(route: routeClinicDashboard, icon: const Icon(Icons.home_outlined), selectedIcon: const Icon(Icons.home_rounded), label: 'Ana Sayfa', visible: (_) => true),
|
||||||
_NavItem(route: routeClinicJobs, icon: const Icon(Icons.work_outline_rounded), selectedIcon: const Icon(Icons.work_rounded), label: 'İşler', visible: (m) => m?.showJobs ?? true),
|
_NavItem(route: routeClinicJobs, icon: const Icon(Icons.work_outline_rounded), selectedIcon: const Icon(Icons.work_rounded), label: 'İşler', visible: (m) => m?.showJobs ?? true),
|
||||||
_NavItem(route: routeClinicPatients, icon: const Icon(Icons.people_outline_rounded), selectedIcon: const Icon(Icons.people_rounded), label: 'Hastalar', visible: (m) => m?.showPatients ?? true),
|
_NavItem(route: routeClinicPatients, icon: const Icon(Icons.people_outline_rounded), selectedIcon: const Icon(Icons.people_rounded), label: 'Hastalar', visible: (m) => m?.showPatients ?? true),
|
||||||
@@ -186,35 +249,70 @@ class _ClinicShellState extends ConsumerState<_ClinicShell> {
|
|||||||
_NavItem(route: routeClinicSettings, icon: const Icon(Icons.settings_outlined), selectedIcon: const Icon(Icons.settings_rounded), label: 'Ayarlar', visible: (_) => true),
|
_NavItem(route: routeClinicSettings, icon: const Icon(Icons.settings_outlined), selectedIcon: const Icon(Icons.settings_rounded), label: 'Ayarlar', visible: (_) => true),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
List<_SidebarEntry> _allEntries() {
|
||||||
|
final membership = ref.read(authProvider).activeTenant;
|
||||||
|
final entries = <_SidebarEntry>[];
|
||||||
|
for (final s in _topSingles) {
|
||||||
|
if (s.visible(membership)) entries.add(_SidebarSingleEntry(s));
|
||||||
|
}
|
||||||
|
for (final g in _groups) {
|
||||||
|
if (g.hasVisible(membership)) entries.add(_SidebarGroupEntry(g));
|
||||||
|
}
|
||||||
|
for (final s in _bottomSingles) {
|
||||||
|
if (s.visible(membership)) entries.add(_SidebarSingleEntry(s));
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final membership = ref.watch(authProvider).activeTenant;
|
|
||||||
final items = _allItems.where((it) => it.visible(membership)).toList();
|
|
||||||
final clampedIndex = _index.clamp(0, items.length - 1);
|
|
||||||
final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
|
final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
|
||||||
|
|
||||||
void onTap(int i) {
|
|
||||||
setState(() => _index = i);
|
|
||||||
context.go(items[i].route);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isDesktop) {
|
if (isDesktop) {
|
||||||
|
final entries = _allEntries();
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppColors.background,
|
backgroundColor: AppColors.background,
|
||||||
body: Row(
|
body: Row(
|
||||||
children: [
|
children: [
|
||||||
_DesktopSidebar(destinations: items, selectedIndex: clampedIndex, onTap: onTap),
|
_DesktopSidebar(
|
||||||
|
entries: entries,
|
||||||
|
selectedRoute: _selectedRoute,
|
||||||
|
onSelectRoute: (route) {
|
||||||
|
setState(() => _selectedRoute = route);
|
||||||
|
context.go(route);
|
||||||
|
},
|
||||||
|
),
|
||||||
Expanded(child: widget.child),
|
Expanded(child: widget.child),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mobile: only core items in bottom nav
|
||||||
|
final membership = ref.read(authProvider).activeTenant;
|
||||||
|
final items = _mobileItems.where((it) => it.visible(membership)).toList();
|
||||||
|
final flatIndex = items.indexWhere((it) => it.route == _selectedRoute);
|
||||||
|
final clampedIndex = flatIndex >= 0 ? flatIndex : 0;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: widget.child,
|
body: widget.child,
|
||||||
|
floatingActionButton: FloatingActionButton.small(
|
||||||
|
heroTag: 'ai_fab_clinic',
|
||||||
|
backgroundColor: AppColors.primary,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
elevation: 3,
|
||||||
|
onPressed: () {
|
||||||
|
setState(() => _selectedRoute = routeClinicAi);
|
||||||
|
context.go(routeClinicAi);
|
||||||
|
},
|
||||||
|
child: const Icon(Icons.auto_awesome_rounded, size: 20),
|
||||||
|
),
|
||||||
bottomNavigationBar: NavigationBar(
|
bottomNavigationBar: NavigationBar(
|
||||||
selectedIndex: clampedIndex,
|
selectedIndex: clampedIndex.clamp(0, items.length - 1),
|
||||||
onDestinationSelected: onTap,
|
onDestinationSelected: (i) {
|
||||||
|
setState(() => _selectedRoute = items[i].route);
|
||||||
|
context.go(items[i].route);
|
||||||
|
},
|
||||||
destinations: [
|
destinations: [
|
||||||
for (final it in items)
|
for (final it in items)
|
||||||
Semantics(
|
Semantics(
|
||||||
@@ -239,9 +337,38 @@ class _LabShell extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _LabShellState extends ConsumerState<_LabShell> {
|
class _LabShellState extends ConsumerState<_LabShell> {
|
||||||
int _index = 0;
|
String _selectedRoute = routeLabDashboard;
|
||||||
|
|
||||||
static final _allItems = [
|
// Top-level singles before groups
|
||||||
|
static final _topSingles = [
|
||||||
|
_NavItem(route: routeLabDashboard, icon: const Icon(Icons.home_outlined), selectedIcon: const Icon(Icons.home_rounded), label: 'Ana Sayfa', visible: (_) => true),
|
||||||
|
_NavItem(route: routeLabJobsAll, icon: const Icon(Icons.work_outline_rounded), selectedIcon: const Icon(Icons.work_rounded), label: 'İşler', visible: (m) => m?.showJobs ?? true),
|
||||||
|
_NavItem(route: routeLabProducts, icon: const Icon(Icons.inventory_2_outlined), selectedIcon: const Icon(Icons.inventory_2_rounded), label: 'Ürünler', visible: (m) => m?.showProducts ?? true),
|
||||||
|
_NavItem(route: routeLabFinance, icon: const Icon(Icons.account_balance_outlined), selectedIcon: const Icon(Icons.account_balance_rounded), label: 'Finans', visible: (m) => m?.showFinance ?? true),
|
||||||
|
_NavItem(route: routeLabAi, icon: const Icon(Icons.auto_awesome_outlined), selectedIcon: const Icon(Icons.auto_awesome_rounded), label: 'AI Sohbet', visible: (_) => true),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Dropdown groups
|
||||||
|
static final _groups = [
|
||||||
|
_NavGroup(
|
||||||
|
title: 'Yönetim',
|
||||||
|
icon: Icons.tune_rounded,
|
||||||
|
selectedIcon: Icons.tune_rounded,
|
||||||
|
items: [
|
||||||
|
_NavItem(route: routeLabConnections, icon: const Icon(Icons.link_rounded), selectedIcon: const Icon(Icons.link_rounded), label: 'Bağlantılar', visible: (_) => true),
|
||||||
|
_NavItem(route: routeLabDiscounts, icon: const Icon(Icons.local_offer_outlined), selectedIcon: const Icon(Icons.local_offer_rounded), label: 'İndirimler', visible: (_) => true),
|
||||||
|
_NavItem(route: routeLabReports, icon: const Icon(Icons.bar_chart_outlined), selectedIcon: const Icon(Icons.bar_chart_rounded), label: 'Raporlar', visible: (_) => true),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Singles after groups
|
||||||
|
static final _bottomSingles = [
|
||||||
|
_NavItem(route: routeLabSettings, icon: const Icon(Icons.settings_outlined), selectedIcon: const Icon(Icons.settings_rounded), label: 'Ayarlar', visible: (_) => true),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mobile bottom nav: core items; others accessed from settings
|
||||||
|
static final _mobileItems = [
|
||||||
_NavItem(route: routeLabDashboard, icon: const Icon(Icons.home_outlined), selectedIcon: const Icon(Icons.home_rounded), label: 'Ana Sayfa', visible: (_) => true),
|
_NavItem(route: routeLabDashboard, icon: const Icon(Icons.home_outlined), selectedIcon: const Icon(Icons.home_rounded), label: 'Ana Sayfa', visible: (_) => true),
|
||||||
_NavItem(route: routeLabJobsAll, icon: const Icon(Icons.work_outline_rounded), selectedIcon: const Icon(Icons.work_rounded), label: 'İşler', visible: (m) => m?.showJobs ?? true),
|
_NavItem(route: routeLabJobsAll, icon: const Icon(Icons.work_outline_rounded), selectedIcon: const Icon(Icons.work_rounded), label: 'İşler', visible: (m) => m?.showJobs ?? true),
|
||||||
_NavItem(route: routeLabProducts, icon: const Icon(Icons.inventory_2_outlined), selectedIcon: const Icon(Icons.inventory_2_rounded), label: 'Ürünler', visible: (m) => m?.showProducts ?? true),
|
_NavItem(route: routeLabProducts, icon: const Icon(Icons.inventory_2_outlined), selectedIcon: const Icon(Icons.inventory_2_rounded), label: 'Ürünler', visible: (m) => m?.showProducts ?? true),
|
||||||
@@ -249,35 +376,70 @@ class _LabShellState extends ConsumerState<_LabShell> {
|
|||||||
_NavItem(route: routeLabSettings, icon: const Icon(Icons.settings_outlined), selectedIcon: const Icon(Icons.settings_rounded), label: 'Ayarlar', visible: (_) => true),
|
_NavItem(route: routeLabSettings, icon: const Icon(Icons.settings_outlined), selectedIcon: const Icon(Icons.settings_rounded), label: 'Ayarlar', visible: (_) => true),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
List<_SidebarEntry> _allEntries() {
|
||||||
|
final membership = ref.read(authProvider).activeTenant;
|
||||||
|
final entries = <_SidebarEntry>[];
|
||||||
|
for (final s in _topSingles) {
|
||||||
|
if (s.visible(membership)) entries.add(_SidebarSingleEntry(s));
|
||||||
|
}
|
||||||
|
for (final g in _groups) {
|
||||||
|
if (g.hasVisible(membership)) entries.add(_SidebarGroupEntry(g));
|
||||||
|
}
|
||||||
|
for (final s in _bottomSingles) {
|
||||||
|
if (s.visible(membership)) entries.add(_SidebarSingleEntry(s));
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final membership = ref.watch(authProvider).activeTenant;
|
|
||||||
final items = _allItems.where((it) => it.visible(membership)).toList();
|
|
||||||
final clampedIndex = _index.clamp(0, items.length - 1);
|
|
||||||
final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
|
final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
|
||||||
|
|
||||||
void onTap(int i) {
|
|
||||||
setState(() => _index = i);
|
|
||||||
context.go(items[i].route);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isDesktop) {
|
if (isDesktop) {
|
||||||
|
final entries = _allEntries();
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppColors.background,
|
backgroundColor: AppColors.background,
|
||||||
body: Row(
|
body: Row(
|
||||||
children: [
|
children: [
|
||||||
_DesktopSidebar(destinations: items, selectedIndex: clampedIndex, onTap: onTap),
|
_DesktopSidebar(
|
||||||
|
entries: entries,
|
||||||
|
selectedRoute: _selectedRoute,
|
||||||
|
onSelectRoute: (route) {
|
||||||
|
setState(() => _selectedRoute = route);
|
||||||
|
context.go(route);
|
||||||
|
},
|
||||||
|
),
|
||||||
Expanded(child: widget.child),
|
Expanded(child: widget.child),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mobile: only core items in bottom nav
|
||||||
|
final membership = ref.read(authProvider).activeTenant;
|
||||||
|
final items = _mobileItems.where((it) => it.visible(membership)).toList();
|
||||||
|
final flatIndex = items.indexWhere((it) => it.route == _selectedRoute);
|
||||||
|
final clampedIndex = flatIndex >= 0 ? flatIndex : 0;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: widget.child,
|
body: widget.child,
|
||||||
|
floatingActionButton: FloatingActionButton.small(
|
||||||
|
heroTag: 'ai_fab_lab',
|
||||||
|
backgroundColor: AppColors.primary,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
elevation: 3,
|
||||||
|
onPressed: () {
|
||||||
|
setState(() => _selectedRoute = routeLabAi);
|
||||||
|
context.go(routeLabAi);
|
||||||
|
},
|
||||||
|
child: const Icon(Icons.auto_awesome_rounded, size: 20),
|
||||||
|
),
|
||||||
bottomNavigationBar: NavigationBar(
|
bottomNavigationBar: NavigationBar(
|
||||||
selectedIndex: clampedIndex,
|
selectedIndex: clampedIndex.clamp(0, items.length - 1),
|
||||||
onDestinationSelected: onTap,
|
onDestinationSelected: (i) {
|
||||||
|
setState(() => _selectedRoute = items[i].route);
|
||||||
|
context.go(items[i].route);
|
||||||
|
},
|
||||||
destinations: [
|
destinations: [
|
||||||
for (final it in items)
|
for (final it in items)
|
||||||
Semantics(
|
Semantics(
|
||||||
@@ -295,14 +457,14 @@ class _LabShellState extends ConsumerState<_LabShell> {
|
|||||||
|
|
||||||
class _DesktopSidebar extends StatefulWidget {
|
class _DesktopSidebar extends StatefulWidget {
|
||||||
const _DesktopSidebar({
|
const _DesktopSidebar({
|
||||||
required this.destinations,
|
required this.entries,
|
||||||
required this.selectedIndex,
|
required this.selectedRoute,
|
||||||
required this.onTap,
|
required this.onSelectRoute,
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<_NavItem> destinations;
|
final List<_SidebarEntry> entries;
|
||||||
final int selectedIndex;
|
final String selectedRoute;
|
||||||
final ValueChanged<int> onTap;
|
final ValueChanged<String> onSelectRoute;
|
||||||
|
|
||||||
// Must match the toolbarHeight used in desktop SliverAppBar headers
|
// Must match the toolbarHeight used in desktop SliverAppBar headers
|
||||||
static const double headerHeight = 64;
|
static const double headerHeight = 64;
|
||||||
@@ -361,21 +523,14 @@ class _DesktopSidebarState extends State<_DesktopSidebar> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Nav items
|
// Nav entries (singles + groups)
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
for (int i = 0; i < widget.destinations.length; i++)
|
for (final entry in widget.entries)
|
||||||
_SidebarItem(
|
_buildEntry(entry),
|
||||||
icon: widget.destinations[i].icon,
|
|
||||||
selectedIcon: widget.destinations[i].selectedIcon,
|
|
||||||
label: widget.destinations[i].label,
|
|
||||||
selected: widget.selectedIndex == i,
|
|
||||||
open: _open,
|
|
||||||
onTap: () => widget.onTap(i),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -417,6 +572,27 @@ class _DesktopSidebarState extends State<_DesktopSidebar> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildEntry(_SidebarEntry entry) {
|
||||||
|
if (entry case final _SidebarSingleEntry single) {
|
||||||
|
return _SidebarItem(
|
||||||
|
icon: single.item.icon,
|
||||||
|
selectedIcon: single.item.selectedIcon,
|
||||||
|
label: single.item.label,
|
||||||
|
selected: widget.selectedRoute == single.item.route,
|
||||||
|
open: _open,
|
||||||
|
onTap: () => widget.onSelectRoute(single.item.route),
|
||||||
|
);
|
||||||
|
} else if (entry case final _SidebarGroupEntry group) {
|
||||||
|
return _SidebarGroup(
|
||||||
|
group: group.group,
|
||||||
|
selectedRoute: widget.selectedRoute,
|
||||||
|
open: _open,
|
||||||
|
onSelectRoute: widget.onSelectRoute,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Sidebar nav item ──────────────────────────────────────────────────────────
|
// ── Sidebar nav item ──────────────────────────────────────────────────────────
|
||||||
@@ -494,3 +670,163 @@ class _SidebarItem extends StatelessWidget {
|
|||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Sidebar group (accordion dropdown) ────────────────────────────────────────
|
||||||
|
|
||||||
|
class _SidebarGroup extends StatefulWidget {
|
||||||
|
const _SidebarGroup({
|
||||||
|
required this.group,
|
||||||
|
required this.selectedRoute,
|
||||||
|
required this.open,
|
||||||
|
required this.onSelectRoute,
|
||||||
|
});
|
||||||
|
|
||||||
|
final _NavGroup group;
|
||||||
|
final String selectedRoute;
|
||||||
|
final bool open;
|
||||||
|
final ValueChanged<String> onSelectRoute;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_SidebarGroup> createState() => _SidebarGroupState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SidebarGroupState extends State<_SidebarGroup> {
|
||||||
|
bool _expanded = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Auto-expand if any child is selected
|
||||||
|
if (widget.group.containsRoute(widget.selectedRoute)) {
|
||||||
|
_expanded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(_SidebarGroup old) {
|
||||||
|
super.didUpdateWidget(old);
|
||||||
|
if (widget.group.containsRoute(widget.selectedRoute) && !_expanded) {
|
||||||
|
setState(() => _expanded = true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isSelected = widget.group.containsRoute(widget.selectedRoute);
|
||||||
|
|
||||||
|
if (!widget.open) {
|
||||||
|
// Collapsed sidebar: show group icon only, tooltip with title
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
|
child: Tooltip(
|
||||||
|
message: widget.group.title,
|
||||||
|
preferBelow: false,
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () {
|
||||||
|
// Toggle expanded and show first visible item
|
||||||
|
if (!_expanded) {
|
||||||
|
setState(() => _expanded = true);
|
||||||
|
}
|
||||||
|
// If already expanded, navigate to first item
|
||||||
|
final first = widget.group.items.firstWhere(
|
||||||
|
(it) => true,
|
||||||
|
orElse: () => widget.group.items.first,
|
||||||
|
);
|
||||||
|
widget.onSelectRoute(first.route);
|
||||||
|
},
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
child: SizedBox(
|
||||||
|
height: 40,
|
||||||
|
child: Center(
|
||||||
|
child: Icon(
|
||||||
|
isSelected ? widget.group.selectedIcon : widget.group.icon,
|
||||||
|
size: 20,
|
||||||
|
color: isSelected ? AppColors.primary : AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
// Group header (clickable to expand/collapse)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => setState(() => _expanded = !_expanded),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
child: SizedBox(
|
||||||
|
height: 40,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
isSelected ? widget.group.selectedIcon : widget.group.icon,
|
||||||
|
size: 20,
|
||||||
|
color: isSelected ? AppColors.primary : AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
widget.group.title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
AnimatedRotation(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
turns: _expanded ? 0.5 : 0,
|
||||||
|
child: const Icon(
|
||||||
|
Icons.keyboard_arrow_down_rounded,
|
||||||
|
size: 18,
|
||||||
|
color: AppColors.textMuted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Sub-items (animated expand/collapse)
|
||||||
|
AnimatedCrossFade(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
crossFadeState: _expanded ? CrossFadeState.showFirst : CrossFadeState.showSecond,
|
||||||
|
firstChild: Column(
|
||||||
|
children: [
|
||||||
|
for (final item in widget.group.items)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 20),
|
||||||
|
child: _SidebarItem(
|
||||||
|
icon: item.icon,
|
||||||
|
selectedIcon: item.selectedIcon,
|
||||||
|
label: item.label,
|
||||||
|
selected: widget.selectedRoute == item.route,
|
||||||
|
open: true,
|
||||||
|
onTap: () => widget.onSelectRoute(item.route),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
secondChild: const SizedBox(width: double.infinity, height: 0),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user