Files
lab-app/lib/core/router/app_router.dart
T

833 lines
33 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../theme/app_theme.dart';
import '../widgets/tooth_logo.dart';
import '../providers/auth_provider.dart';
import '../../models/tenant.dart';
import '../../features/auth/sign_in_screen.dart';
import '../../features/auth/sign_up_screen.dart';
import '../../features/auth/onboarding_screen.dart';
import '../../features/clinic/dashboard/clinic_dashboard_screen.dart';
import '../../features/clinic/jobs/clinic_jobs_screen.dart';
import '../../features/clinic/jobs/clinic_job_detail_screen.dart';
import '../../features/clinic/jobs/new_job_screen.dart';
import '../../features/clinic/patients/clinic_patients_screen.dart';
import '../../features/clinic/patients/clinic_patient_detail_screen.dart';
import '../../features/clinic/connections/clinic_connections_screen.dart';
import '../../features/clinic/finance/clinic_finance_screen.dart';
import '../../features/clinic/settings/clinic_settings_screen.dart';
import '../../features/lab/dashboard/lab_dashboard_screen.dart';
import '../../features/lab/jobs/lab_jobs_inbound_screen.dart';
import '../../features/lab/jobs/lab_all_jobs_screen.dart';
import '../../features/lab/jobs/lab_job_detail_screen.dart';
import '../../features/lab/products/lab_products_screen.dart';
import '../../features/lab/connections/lab_connections_screen.dart';
import '../../features/lab/finance/lab_finance_screen.dart';
import '../../features/lab/settings/lab_settings_screen.dart';
import '../../features/shared/reports_screen.dart';
import '../../features/shared/ai_chat_screen.dart';
import '../../features/lab/discounts/discounts_screen.dart';
import '../../features/lab/connections/connection_detail_screen.dart';
import '../../models/connection.dart';
// Auth routes
const routeSignIn = '/sign-in';
const routeSignUp = '/sign-up';
const routeOnboarding = '/onboarding';
// Clinic routes
const routeClinicDashboard = '/clinic/dashboard';
const routeClinicJobs = '/clinic/jobs';
const routeClinicJobDetail = '/clinic/jobs/:jobId';
const routeClinicJobNew = '/clinic/jobs/new';
const routeClinicPatients = '/clinic/patients';
const routeClinicPatientDetail = '/clinic/patients/:patientId';
const routeClinicConnections = '/clinic/connections';
const routeClinicFinance = '/clinic/finance';
const routeClinicSettings = '/clinic/settings';
const routeClinicReports = '/clinic/reports';
const routeClinicAi = '/clinic/ai';
// Lab routes
const routeLabDashboard = '/lab/dashboard';
const routeLabJobsInbound = '/lab/jobs/inbound';
const routeLabJobsAll = '/lab/jobs';
const routeLabJobDetail = '/lab/jobs/:jobId';
const routeLabProducts = '/lab/products';
const routeLabConnections = '/lab/connections';
const routeLabFinance = '/lab/finance';
const routeLabSettings = '/lab/settings';
const routeLabReports = '/lab/reports';
const routeLabAi = '/lab/ai';
const routeLabDiscounts = '/lab/discounts';
List<RouteBase> buildRoutes() => [
GoRoute(path: routeSignIn, builder: (_, __) => const SignInScreen()),
GoRoute(path: routeSignUp, builder: (_, __) => const SignUpScreen()),
GoRoute(path: routeOnboarding, builder: (_, __) => const OnboardingScreen()),
// ── Clinic shell ──────────────────────────────────────────────────────
ShellRoute(
builder: (context, state, child) => _ClinicShell(child: child),
routes: [
GoRoute(path: routeClinicDashboard, builder: (_, __) => const ClinicDashboardScreen()),
GoRoute(
path: routeClinicJobs,
builder: (_, __) => const ClinicJobsScreen(),
routes: [
GoRoute(path: 'new', builder: (_, __) => const NewJobScreen()),
GoRoute(
path: ':jobId',
builder: (_, s) => ClinicJobDetailScreen(jobId: s.pathParameters['jobId']!),
),
],
),
GoRoute(
path: routeClinicPatients,
builder: (_, __) => const ClinicPatientsScreen(),
routes: [
GoRoute(
path: ':patientId',
builder: (_, s) => ClinicPatientDetailScreen(patientId: s.pathParameters['patientId']!),
),
],
),
GoRoute(path: routeClinicConnections, builder: (_, __) => const ClinicConnectionsScreen()),
GoRoute(path: routeClinicFinance, builder: (_, __) => const ClinicFinanceScreen()),
GoRoute(path: routeClinicSettings, builder: (_, __) => const ClinicSettingsScreen()),
GoRoute(path: routeClinicReports, builder: (_, __) => const ReportsScreen()),
GoRoute(path: routeClinicAi, builder: (_, __) => const AiChatScreen()),
],
),
// ── Lab shell ─────────────────────────────────────────────────────────
ShellRoute(
builder: (context, state, child) => _LabShell(child: child),
routes: [
GoRoute(path: routeLabDashboard, builder: (_, __) => const LabDashboardScreen()),
GoRoute(path: routeLabJobsInbound, builder: (_, __) => const LabJobsInboundScreen()),
GoRoute(
path: routeLabJobsAll,
builder: (_, __) => const LabAllJobsScreen(),
routes: [
GoRoute(
path: ':jobId',
builder: (_, s) => LabJobDetailScreen(jobId: s.pathParameters['jobId']!),
),
],
),
GoRoute(path: routeLabProducts, builder: (_, __) => const LabProductsScreen()),
GoRoute(
path: routeLabConnections,
builder: (_, __) => const LabConnectionsScreen(),
routes: [
GoRoute(
path: ':connectionId/detail',
builder: (_, s) {
final extra = s.extra as Map<String, dynamic>?;
final connection = extra?['connection'] as Connection?;
final labTenantId = extra?['labTenantId'] as String? ?? '';
if (connection == null) {
return const Scaffold(
body: Center(child: Text('Bağlantı bulunamadı')),
);
}
return ConnectionDetailScreen(
connection: connection, labTenantId: labTenantId);
},
),
],
),
GoRoute(path: routeLabDiscounts, builder: (_, __) => const DiscountsScreen()),
GoRoute(path: routeLabFinance, builder: (_, __) => const LabFinanceScreen()),
GoRoute(path: routeLabSettings, builder: (_, __) => const LabSettingsScreen()),
GoRoute(path: routeLabReports, builder: (_, __) => const ReportsScreen()),
GoRoute(path: routeLabAi, builder: (_, __) => const AiChatScreen()),
],
),
];
// ── Nav item descriptor ───────────────────────────────────────────────────────
class _NavItem {
const _NavItem({
required this.route,
required this.icon,
required this.selectedIcon,
required this.label,
required this.visible,
});
final String route;
final Icon icon;
final Icon selectedIcon;
final String label;
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 {
const _ClinicShell({required this.child});
final Widget child;
@override
ConsumerState<_ClinicShell> createState() => _ClinicShellState();
}
class _ClinicShellState extends ConsumerState<_ClinicShell> {
String _selectedRoute = routeClinicDashboard;
// 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),
_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: 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 isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
if (isDesktop) {
final entries = _allEntries();
return Scaffold(
backgroundColor: AppColors.background,
body: Row(
children: [
_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.clamp(0, items.length - 1),
onDestinationSelected: (i) {
setState(() => _selectedRoute = items[i].route);
context.go(items[i].route);
},
destinations: [
for (final it in items)
Semantics(
label: it.label,
button: true,
child: NavigationDestination(icon: it.icon, selectedIcon: it.selectedIcon, label: it.label),
),
],
),
);
}
}
// ── Lab shell ─────────────────────────────────────────────────────────────────
class _LabShell extends ConsumerStatefulWidget {
const _LabShell({required this.child});
final Widget child;
@override
ConsumerState<_LabShell> createState() => _LabShellState();
}
class _LabShellState extends ConsumerState<_LabShell> {
String _selectedRoute = routeLabDashboard;
// 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),
_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: 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 isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
if (isDesktop) {
final entries = _allEntries();
return Scaffold(
backgroundColor: AppColors.background,
body: Row(
children: [
_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.clamp(0, items.length - 1),
onDestinationSelected: (i) {
setState(() => _selectedRoute = items[i].route);
context.go(items[i].route);
},
destinations: [
for (final it in items)
Semantics(
label: it.label,
button: true,
child: NavigationDestination(icon: it.icon, selectedIcon: it.selectedIcon, label: it.label),
),
],
),
);
}
}
// ── Desktop sidebar ───────────────────────────────────────────────────────────
class _DesktopSidebar extends StatefulWidget {
const _DesktopSidebar({
required this.entries,
required this.selectedRoute,
required this.onSelectRoute,
});
final List<_SidebarEntry> entries;
final String selectedRoute;
final ValueChanged<String> onSelectRoute;
// Must match the toolbarHeight used in desktop SliverAppBar headers
static const double headerHeight = 64;
static const double _openWidth = 220;
static const double _closedWidth = 64;
@override
State<_DesktopSidebar> createState() => _DesktopSidebarState();
}
class _DesktopSidebarState extends State<_DesktopSidebar> {
bool _open = true;
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: const Duration(milliseconds: 220),
curve: Curves.easeInOut,
width: _open ? _DesktopSidebar._openWidth : _DesktopSidebar._closedWidth,
decoration: const BoxDecoration(
color: AppColors.surface,
border: Border(right: BorderSide(color: AppColors.border)),
boxShadow: [BoxShadow(color: Color(0x08000000), blurRadius: 8, offset: Offset(2, 0))],
),
child: ClipRect(
child: Column(
children: [
// Header
Container(
height: _DesktopSidebar.headerHeight,
decoration: const BoxDecoration(
gradient: LinearGradient(colors: [AppColors.primary, AppColors.accent]),
border: Border(bottom: BorderSide(color: AppColors.border)),
),
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(9),
border: Border.all(color: Colors.white.withValues(alpha: 0.25)),
),
child: const Center(child: ToothLogo(size: 18, color: Colors.white)),
),
if (_open) ...[
const SizedBox(width: 10),
const Text(
'DLS',
style: TextStyle(color: Colors.white, fontSize: 17, fontWeight: FontWeight.w800, letterSpacing: 1),
),
],
],
),
),
// Nav entries (singles + groups)
Expanded(
child: SingleChildScrollView(
child: Column(
children: [
const SizedBox(height: 8),
for (final entry in widget.entries)
_buildEntry(entry),
const SizedBox(height: 8),
],
),
),
),
// Toggle button
Container(
decoration: const BoxDecoration(
border: Border(top: BorderSide(color: AppColors.border)),
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () => setState(() => _open = !_open),
child: SizedBox(
height: 48,
child: Row(
mainAxisAlignment: _open ? MainAxisAlignment.start : MainAxisAlignment.center,
children: [
if (_open) const SizedBox(width: 20),
AnimatedRotation(
duration: const Duration(milliseconds: 220),
turns: _open ? 0.5 : 0,
child: const Icon(Icons.chevron_right_rounded, color: AppColors.textMuted, size: 20),
),
if (_open) ...[
const SizedBox(width: 8),
const Text('Daralt', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: AppColors.textMuted)),
],
],
),
),
),
),
),
],
),
),
);
}
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 ──────────────────────────────────────────────────────────
class _SidebarItem extends StatelessWidget {
const _SidebarItem({
required this.icon,
required this.selectedIcon,
required this.label,
required this.selected,
required this.open,
required this.onTap,
});
final Widget icon;
final Widget selectedIcon;
final String label;
final bool selected;
final bool open;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final item = Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
child: Material(
color: selected ? const Color(0xFFDBEAFE) : Colors.transparent,
borderRadius: BorderRadius.circular(10),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(10),
child: SizedBox(
height: 40,
child: open
? Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
IconTheme(
data: IconThemeData(
color: selected ? AppColors.primary : AppColors.textSecondary,
size: 20,
),
child: selected ? selectedIcon : icon,
),
const SizedBox(width: 12),
Text(
label,
style: TextStyle(
fontSize: 14,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
color: selected ? AppColors.primary : AppColors.textSecondary,
),
),
],
),
)
: Center(
child: IconTheme(
data: IconThemeData(
color: selected ? AppColors.primary : AppColors.textSecondary,
size: 20,
),
child: selected ? selectedIcon : icon,
),
),
),
),
),
);
if (!open) {
return Tooltip(message: label, preferBelow: false, child: 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),
),
],
);
}
}