diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index e942c67..4341da7 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -165,6 +165,41 @@ class _NavItem { 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 ────────────────────────────────────────────────────────────── class _ClinicShell extends ConsumerStatefulWidget { @@ -176,9 +211,37 @@ class _ClinicShell extends ConsumerStatefulWidget { } 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: 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), @@ -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), ]; + 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 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; - void onTap(int i) { - setState(() => _index = i); - context.go(items[i].route); - } - if (isDesktop) { + final entries = _allEntries(); return Scaffold( backgroundColor: AppColors.background, body: Row( 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), ], ), ); } + // 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( 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( - selectedIndex: clampedIndex, - onDestinationSelected: onTap, + selectedIndex: clampedIndex.clamp(0, items.length - 1), + onDestinationSelected: (i) { + setState(() => _selectedRoute = items[i].route); + context.go(items[i].route); + }, destinations: [ for (final it in items) Semantics( @@ -239,9 +337,38 @@ class _LabShell extends ConsumerStatefulWidget { } 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: 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), @@ -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), ]; + 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 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; - void onTap(int i) { - setState(() => _index = i); - context.go(items[i].route); - } - if (isDesktop) { + final entries = _allEntries(); return Scaffold( backgroundColor: AppColors.background, body: Row( 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), ], ), ); } + // 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( 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( - selectedIndex: clampedIndex, - onDestinationSelected: onTap, + selectedIndex: clampedIndex.clamp(0, items.length - 1), + onDestinationSelected: (i) { + setState(() => _selectedRoute = items[i].route); + context.go(items[i].route); + }, destinations: [ for (final it in items) Semantics( @@ -295,14 +457,14 @@ class _LabShellState extends ConsumerState<_LabShell> { class _DesktopSidebar extends StatefulWidget { const _DesktopSidebar({ - required this.destinations, - required this.selectedIndex, - required this.onTap, + required this.entries, + required this.selectedRoute, + required this.onSelectRoute, }); - final List<_NavItem> destinations; - final int selectedIndex; - final ValueChanged onTap; + final List<_SidebarEntry> entries; + final String selectedRoute; + final ValueChanged onSelectRoute; // Must match the toolbarHeight used in desktop SliverAppBar headers static const double headerHeight = 64; @@ -361,21 +523,14 @@ class _DesktopSidebarState extends State<_DesktopSidebar> { ), ), - // Nav items + // Nav entries (singles + groups) Expanded( child: SingleChildScrollView( child: Column( children: [ const SizedBox(height: 8), - for (int i = 0; i < widget.destinations.length; i++) - _SidebarItem( - 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), - ), + for (final entry in widget.entries) + _buildEntry(entry), 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 ────────────────────────────────────────────────────────── @@ -494,3 +670,163 @@ class _SidebarItem extends StatelessWidget { 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 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), + ), + ], + ); + } +}