1036 lines
37 KiB
Dart
1036 lines
37 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:go_router/go_router.dart';
|
||
import '../l10n/app_strings.dart';
|
||
import '../providers/locale_provider.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/auth/welcome_pricing_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';
|
||
const routeWelcome = '/welcome';
|
||
|
||
// 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: routeWelcome, builder: (_, __) => const WelcomePricingScreen()),
|
||
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;
|
||
|
||
List<_NavItem> _clinicTopSingles(AppStrings s) => [
|
||
_NavItem(
|
||
route: routeClinicDashboard,
|
||
icon: const Icon(Icons.home_outlined),
|
||
selectedIcon: const Icon(Icons.home_rounded),
|
||
label: s.homeTitle,
|
||
visible: (_) => true),
|
||
_NavItem(
|
||
route: routeClinicJobs,
|
||
icon: const Icon(Icons.work_outline_rounded),
|
||
selectedIcon: const Icon(Icons.work_rounded),
|
||
label: s.jobsTitle,
|
||
visible: (m) => m?.showJobs ?? true),
|
||
_NavItem(
|
||
route: routeClinicPatients,
|
||
icon: const Icon(Icons.people_outline_rounded),
|
||
selectedIcon: const Icon(Icons.people_rounded),
|
||
label: s.patientsTitle,
|
||
visible: (m) => m?.showPatients ?? true),
|
||
_NavItem(
|
||
route: routeClinicFinance,
|
||
icon: const Icon(Icons.account_balance_outlined),
|
||
selectedIcon: const Icon(Icons.account_balance_rounded),
|
||
label: s.finance,
|
||
visible: (m) => m?.showFinance ?? true),
|
||
_NavItem(
|
||
route: routeClinicAi,
|
||
icon: const Icon(Icons.auto_awesome_outlined),
|
||
selectedIcon: const Icon(Icons.auto_awesome_rounded),
|
||
label: s.aiAssistant,
|
||
visible: (_) => true),
|
||
];
|
||
|
||
List<_NavGroup> _clinicGroups(AppStrings s) => [
|
||
_NavGroup(
|
||
title: s.management,
|
||
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: s.connections,
|
||
visible: (_) => true),
|
||
_NavItem(
|
||
route: routeClinicReports,
|
||
icon: const Icon(Icons.bar_chart_outlined),
|
||
selectedIcon: const Icon(Icons.bar_chart_rounded),
|
||
label: s.reports,
|
||
visible: (_) => true),
|
||
],
|
||
),
|
||
];
|
||
|
||
List<_NavItem> _clinicBottomSingles(AppStrings s) => [
|
||
_NavItem(
|
||
route: routeClinicSettings,
|
||
icon: const Icon(Icons.settings_outlined),
|
||
selectedIcon: const Icon(Icons.settings_rounded),
|
||
label: s.settings,
|
||
visible: (_) => true),
|
||
];
|
||
|
||
List<_NavItem> _clinicMobileItems(AppStrings s) => [
|
||
_NavItem(
|
||
route: routeClinicDashboard,
|
||
icon: const Icon(Icons.home_outlined),
|
||
selectedIcon: const Icon(Icons.home_rounded),
|
||
label: s.homeTitle,
|
||
visible: (_) => true),
|
||
_NavItem(
|
||
route: routeClinicJobs,
|
||
icon: const Icon(Icons.work_outline_rounded),
|
||
selectedIcon: const Icon(Icons.work_rounded),
|
||
label: s.jobsTitle,
|
||
visible: (m) => m?.showJobs ?? true),
|
||
_NavItem(
|
||
route: routeClinicPatients,
|
||
icon: const Icon(Icons.people_outline_rounded),
|
||
selectedIcon: const Icon(Icons.people_rounded),
|
||
label: s.patientsTitle,
|
||
visible: (m) => m?.showPatients ?? true),
|
||
_NavItem(
|
||
route: routeClinicFinance,
|
||
icon: const Icon(Icons.account_balance_outlined),
|
||
selectedIcon: const Icon(Icons.account_balance_rounded),
|
||
label: s.finance,
|
||
visible: (m) => m?.showFinance ?? true),
|
||
_NavItem(
|
||
route: routeClinicSettings,
|
||
icon: const Icon(Icons.settings_outlined),
|
||
selectedIcon: const Icon(Icons.settings_rounded),
|
||
label: s.settings,
|
||
visible: (_) => true),
|
||
];
|
||
|
||
List<_SidebarEntry> _allEntries(AppStrings s) {
|
||
final membership = ref.read(authProvider).activeTenant;
|
||
final entries = <_SidebarEntry>[];
|
||
for (final item in _clinicTopSingles(s)) {
|
||
if (item.visible(membership)) entries.add(_SidebarSingleEntry(item));
|
||
}
|
||
for (final group in _clinicGroups(s)) {
|
||
if (group.hasVisible(membership)) entries.add(_SidebarGroupEntry(group));
|
||
}
|
||
for (final item in _clinicBottomSingles(s)) {
|
||
if (item.visible(membership)) entries.add(_SidebarSingleEntry(item));
|
||
}
|
||
return entries;
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final s = ref.watch(stringsProvider);
|
||
final isDesktop =
|
||
MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
|
||
|
||
if (isDesktop) {
|
||
final entries = _allEntries(s);
|
||
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 =
|
||
_clinicMobileItems(s).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;
|
||
|
||
List<_NavItem> _labTopSingles(AppStrings s) => [
|
||
_NavItem(
|
||
route: routeLabDashboard,
|
||
icon: const Icon(Icons.home_outlined),
|
||
selectedIcon: const Icon(Icons.home_rounded),
|
||
label: s.homeTitle,
|
||
visible: (_) => true),
|
||
_NavItem(
|
||
route: routeLabJobsAll,
|
||
icon: const Icon(Icons.work_outline_rounded),
|
||
selectedIcon: const Icon(Icons.work_rounded),
|
||
label: s.jobsTitle,
|
||
visible: (m) => m?.showJobs ?? true),
|
||
_NavItem(
|
||
route: routeLabProducts,
|
||
icon: const Icon(Icons.inventory_2_outlined),
|
||
selectedIcon: const Icon(Icons.inventory_2_rounded),
|
||
label: s.productsTitle,
|
||
visible: (m) => m?.showProducts ?? true),
|
||
_NavItem(
|
||
route: routeLabFinance,
|
||
icon: const Icon(Icons.account_balance_outlined),
|
||
selectedIcon: const Icon(Icons.account_balance_rounded),
|
||
label: s.finance,
|
||
visible: (m) => m?.showFinance ?? true),
|
||
_NavItem(
|
||
route: routeLabAi,
|
||
icon: const Icon(Icons.auto_awesome_outlined),
|
||
selectedIcon: const Icon(Icons.auto_awesome_rounded),
|
||
label: s.aiAssistant,
|
||
visible: (_) => true),
|
||
];
|
||
|
||
List<_NavGroup> _labGroups(AppStrings s) => [
|
||
_NavGroup(
|
||
title: s.management,
|
||
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: s.connections,
|
||
visible: (_) => true),
|
||
_NavItem(
|
||
route: routeLabDiscounts,
|
||
icon: const Icon(Icons.local_offer_outlined),
|
||
selectedIcon: const Icon(Icons.local_offer_rounded),
|
||
label: s.discounts,
|
||
visible: (_) => true),
|
||
_NavItem(
|
||
route: routeLabReports,
|
||
icon: const Icon(Icons.bar_chart_outlined),
|
||
selectedIcon: const Icon(Icons.bar_chart_rounded),
|
||
label: s.reports,
|
||
visible: (_) => true),
|
||
],
|
||
),
|
||
];
|
||
|
||
List<_NavItem> _labBottomSingles(AppStrings s) => [
|
||
_NavItem(
|
||
route: routeLabSettings,
|
||
icon: const Icon(Icons.settings_outlined),
|
||
selectedIcon: const Icon(Icons.settings_rounded),
|
||
label: s.settings,
|
||
visible: (_) => true),
|
||
];
|
||
|
||
List<_NavItem> _labMobileItems(AppStrings s) => [
|
||
_NavItem(
|
||
route: routeLabDashboard,
|
||
icon: const Icon(Icons.home_outlined),
|
||
selectedIcon: const Icon(Icons.home_rounded),
|
||
label: s.homeTitle,
|
||
visible: (_) => true),
|
||
_NavItem(
|
||
route: routeLabJobsAll,
|
||
icon: const Icon(Icons.work_outline_rounded),
|
||
selectedIcon: const Icon(Icons.work_rounded),
|
||
label: s.jobsTitle,
|
||
visible: (m) => m?.showJobs ?? true),
|
||
_NavItem(
|
||
route: routeLabProducts,
|
||
icon: const Icon(Icons.inventory_2_outlined),
|
||
selectedIcon: const Icon(Icons.inventory_2_rounded),
|
||
label: s.productsTitle,
|
||
visible: (m) => m?.showProducts ?? true),
|
||
_NavItem(
|
||
route: routeLabFinance,
|
||
icon: const Icon(Icons.account_balance_outlined),
|
||
selectedIcon: const Icon(Icons.account_balance_rounded),
|
||
label: s.finance,
|
||
visible: (m) => m?.showFinance ?? true),
|
||
_NavItem(
|
||
route: routeLabSettings,
|
||
icon: const Icon(Icons.settings_outlined),
|
||
selectedIcon: const Icon(Icons.settings_rounded),
|
||
label: s.settings,
|
||
visible: (_) => true),
|
||
];
|
||
|
||
List<_SidebarEntry> _allEntries(AppStrings s) {
|
||
final membership = ref.read(authProvider).activeTenant;
|
||
final entries = <_SidebarEntry>[];
|
||
for (final item in _labTopSingles(s)) {
|
||
if (item.visible(membership)) entries.add(_SidebarSingleEntry(item));
|
||
}
|
||
for (final group in _labGroups(s)) {
|
||
if (group.hasVisible(membership)) entries.add(_SidebarGroupEntry(group));
|
||
}
|
||
for (final item in _labBottomSingles(s)) {
|
||
if (item.visible(membership)) entries.add(_SidebarSingleEntry(item));
|
||
}
|
||
return entries;
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final s = ref.watch(stringsProvider);
|
||
final isDesktop =
|
||
MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
|
||
|
||
if (isDesktop) {
|
||
final entries = _allEntries(s);
|
||
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 =
|
||
_labMobileItems(s).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),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|