import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart' show kIsWeb; import '../theme/m3_motion.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; // showcaseview removed due to null-safety incompatibility; onboarding shown via dialog import '../providers/auth_provider.dart'; import '../providers/notifications_provider.dart'; import '../providers/profile_provider.dart'; import 'announcement_banner.dart'; import 'app_breakpoints.dart'; import 'offline_readiness_sheet.dart'; import 'profile_avatar.dart'; import 'pass_slip_countdown_banner.dart'; import 'shift_countdown_banner.dart'; final GlobalKey notificationBellKey = GlobalKey(); class AppScaffold extends ConsumerWidget { const AppScaffold({super.key, required this.child}); final Widget child; @override Widget build(BuildContext context, WidgetRef ref) { final profileAsync = ref.watch(currentProfileProvider); final role = profileAsync.maybeWhen( data: (profile) => profile?.role ?? 'standard', orElse: () => 'standard', ); final displayName = profileAsync.maybeWhen( data: (profile) { final name = profile?.fullName.trim() ?? ''; return name.isNotEmpty ? name : 'User'; }, orElse: () => 'User', ); final avatarUrl = profileAsync.maybeWhen( data: (profile) => profile?.avatarUrl, orElse: () => null, ); final isStandard = role == 'standard'; final location = GoRouterState.of(context).uri.toString(); final sections = _buildSections(role); final width = MediaQuery.of(context).size.width; final showRail = width >= AppBreakpoints.tablet; final isExtended = width >= AppBreakpoints.desktop; final railItems = _flattenSections( sections, ).where((item) => !item.isLogout).toList(); final primaryItems = _primaryItemsForRole(role); final mobilePrimary = _mobilePrimaryItems(primaryItems); final overflowItems = _overflowItems(railItems, mobilePrimary); return Scaffold( appBar: AppBar( title: _AppBarTitle( location: location, showRail: showRail, isExtended: isExtended, ), actions: [ _ProfileMenuAnchor( isStandard: isStandard, displayName: displayName, avatarUrl: avatarUrl, onProfile: () => context.go('/profile'), onSignOut: () => ref.read(authControllerProvider).signOut(), ), const _NotificationBell(), ], bottom: PreferredSize( preferredSize: const Size.fromHeight(1), child: Divider( height: 1, thickness: 1, color: Theme.of(context).colorScheme.outlineVariant.withValues(alpha: 0.5), ), ), ), bottomNavigationBar: showRail ? null : AppBottomNav( location: location, items: _mobileNavItems(mobilePrimary, overflowItems), onShowMore: overflowItems.isEmpty ? null : () => _showOverflowSheet( context, overflowItems, () => ref.read(authControllerProvider).signOut(), ), ), body: LayoutBuilder( builder: (context, constraints) { final railWidth = showRail ? (isExtended ? 256.0 : 80.0) : 0.0; return Stack( children: [ Positioned.fill( left: railWidth, child: _ShellBackground(child: child), ), if (showRail) Positioned( left: 0, top: 0, bottom: 0, width: railWidth, child: AppNavigationRail( items: railItems, location: location, extended: isExtended, displayName: displayName, onLogout: () => ref.read(authControllerProvider).signOut(), ), ), ], ); }, ), ); } } class AppNavigationRail extends StatefulWidget { const AppNavigationRail({ super.key, required this.items, required this.location, required this.extended, required this.displayName, required this.onLogout, }); final List items; final String location; final bool extended; final String displayName; final VoidCallback onLogout; @override State createState() => _AppNavigationRailState(); } class _AppNavigationRailState extends State { final _scrollController = ScrollController(); @override void dispose() { _scrollController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final currentIndex = _currentIndex(widget.location, widget.items); // M3 Expressive: tonal surface container instead of a hard border divider. // Custom scrollable layout replaces NavigationRail so items are reachable // on small viewport heights (web, small laptop screens). return Container( decoration: BoxDecoration(color: cs.surfaceContainerLow), child: Column( children: [ Padding( padding: EdgeInsets.symmetric( vertical: widget.extended ? 12 : 8, horizontal: widget.extended ? 16 : 0, ), child: Center( child: Image.asset( 'assets/tasq_ico.png', width: widget.extended ? 48 : 40, height: widget.extended ? 48 : 40, ), ), ), Expanded( child: Scrollbar( controller: _scrollController, child: SingleChildScrollView( controller: _scrollController, child: Column( children: [ for (int i = 0; i < widget.items.length; i++) _NavRailItem( item: widget.items[i], selected: i == currentIndex, extended: widget.extended, onTap: () => widget.items[i] .onTap(context, onLogout: widget.onLogout), ), ], ), ), ), ), Padding( padding: const EdgeInsets.only(bottom: 16), child: IconButton( tooltip: 'Sign out', onPressed: widget.onLogout, icon: const Icon(Icons.logout), ), ), ], ), ); } } class _NavRailItem extends StatelessWidget { const _NavRailItem({ required this.item, required this.selected, required this.extended, required this.onTap, }); final NavItem item; final bool selected; final bool extended; final VoidCallback onTap; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final icon = Icon( selected ? (item.selectedIcon ?? item.icon) : item.icon, size: 24, color: selected ? cs.onSecondaryContainer : cs.onSurfaceVariant, ); final indicator = AnimatedContainer( duration: const Duration(milliseconds: 200), width: 56, height: 32, decoration: BoxDecoration( color: selected ? cs.secondaryContainer : Colors.transparent, borderRadius: BorderRadius.circular(16), ), child: Center(child: icon), ); final Widget content = extended ? Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Row( children: [ indicator, const SizedBox(width: 12), Expanded( child: Text( item.label, style: Theme.of(context).textTheme.labelLarge?.copyWith( color: selected ? cs.onSecondaryContainer : cs.onSurfaceVariant, fontWeight: selected ? FontWeight.w600 : FontWeight.normal, ), overflow: TextOverflow.ellipsis, ), ), ], ), ) : Center(child: indicator); final tile = InkWell( onTap: onTap, borderRadius: BorderRadius.circular(8), child: SizedBox( height: 56, width: double.infinity, child: content, ), ); if (extended) return tile; return Tooltip(message: item.label, child: tile); } } class AppBottomNav extends StatelessWidget { const AppBottomNav({ super.key, required this.location, required this.items, this.onShowMore, }); final String location; final List items; final VoidCallback? onShowMore; @override Widget build(BuildContext context) { final index = _currentIndex(location, items); return NavigationBar( selectedIndex: index, onDestinationSelected: (value) { final item = items[value]; if (item.isOverflow) { onShowMore?.call(); return; } item.onTap(context); }, destinations: [ for (final item in items) NavigationDestination( icon: Icon(item.icon), selectedIcon: Icon(item.selectedIcon ?? item.icon), label: item.label, ), ], ); } } class _NotificationBell extends ConsumerWidget { const _NotificationBell(); @override Widget build(BuildContext context, WidgetRef ref) { final unreadCount = ref.watch(unreadNotificationsCountProvider); final String? badgeLabel = unreadCount == 0 ? null : unreadCount > 99 ? '99+' : unreadCount.toString(); return Padding( padding: const EdgeInsets.only(right: 4), child: IconButton( key: notificationBellKey, tooltip: 'Notifications', onPressed: () => context.go('/notifications'), icon: AnimatedSwitcher( duration: const Duration(milliseconds: 200), switchInCurve: Curves.easeOutBack, switchOutCurve: Curves.easeInCubic, transitionBuilder: (child, animation) => ScaleTransition(scale: animation, child: child), child: Badge( key: ValueKey(badgeLabel), isLabelVisible: unreadCount > 0, label: badgeLabel != null ? Text(badgeLabel) : null, child: Icon( unreadCount > 0 ? Icons.notifications : Icons.notifications_outlined, ), ), ), ), ); } } /// M3 Expressive shell background — uses a subtle tonal surface tint /// rather than a gradient to create the organic, seed-colored feel. class _ShellBackground extends StatelessWidget { const _ShellBackground({required this.child}); final Widget child; @override Widget build(BuildContext context) { return ColoredBox( color: Theme.of(context).scaffoldBackgroundColor, child: ShiftCountdownBanner( child: PassSlipCountdownBanner( child: AnnouncementBanner(child: child), ), ), ); } } class NavItem { NavItem({ required this.label, required this.route, required this.icon, this.selectedIcon, this.isLogout = false, this.isOverflow = false, }); final String label; final String route; final IconData icon; final IconData? selectedIcon; final bool isLogout; final bool isOverflow; void onTap(BuildContext context, {VoidCallback? onLogout}) { if (isLogout) { onLogout?.call(); return; } if (route.isNotEmpty) { context.go(route); } } } class NavSection { NavSection({this.label, required this.items}); final String? label; final List items; } List _buildSections(String role) { final isStandard = role == 'standard'; final mainItems = [ NavItem( label: 'Dashboard', route: '/dashboard', icon: Icons.grid_view, selectedIcon: Icons.grid_view_rounded, ), if (!isStandard) NavItem( label: 'Attendance', route: '/attendance', icon: Icons.fact_check_outlined, selectedIcon: Icons.fact_check, ), if (!isStandard) NavItem( label: 'Whereabouts', route: '/whereabouts', icon: Icons.share_location_outlined, selectedIcon: Icons.share_location, ), NavItem( label: 'Tickets', route: '/tickets', icon: Icons.support_agent_outlined, selectedIcon: Icons.support_agent, ), NavItem( label: 'Tasks', route: '/tasks', icon: Icons.task_outlined, selectedIcon: Icons.task, ), NavItem( label: 'IT Service Requests', route: '/it-service-requests', icon: Icons.miscellaneous_services_outlined, selectedIcon: Icons.miscellaneous_services, ), if (!isStandard) NavItem( label: 'Network Map', route: '/network-map', icon: Icons.hub_outlined, selectedIcon: Icons.hub, ), NavItem( label: 'Announcement', route: '/announcements', icon: Icons.campaign_outlined, selectedIcon: Icons.campaign, ), NavItem( label: 'Workforce', route: '/workforce', icon: Icons.groups_outlined, selectedIcon: Icons.groups, ), NavItem( label: 'Reports', route: '/reports', icon: Icons.analytics_outlined, selectedIcon: Icons.analytics, ), ]; if (role == 'admin' || role == 'programmer' || role == 'dispatcher') { return [ NavSection(label: 'Operations', items: mainItems), NavSection( label: 'Settings', items: [ NavItem( label: 'User Management', route: '/settings/users', icon: Icons.admin_panel_settings_outlined, selectedIcon: Icons.admin_panel_settings, ), NavItem( label: 'Office Management', route: '/settings/offices', icon: Icons.apartment_outlined, selectedIcon: Icons.apartment, ), NavItem( label: 'IT Staff Teams', route: '/settings/teams', icon: Icons.groups_2_outlined, selectedIcon: Icons.groups_2, ), if (kIsWeb) ...[ NavItem( label: 'App Update', route: '/settings/app-update', icon: Icons.system_update_alt, selectedIcon: Icons.system_update, ), ], NavItem( label: 'Logout', route: '', icon: Icons.logout, isLogout: true, ), ], ), ]; } return [ NavSection(label: 'Operations', items: mainItems), NavSection( label: 'Settings', items: [ NavItem( label: 'Logout', route: '', icon: Icons.logout, isLogout: true, ), ], ), ]; } List _standardNavItems() { return [ NavItem( label: 'Dashboard', route: '/dashboard', icon: Icons.grid_view, selectedIcon: Icons.grid_view_rounded, ), NavItem( label: 'Tickets', route: '/tickets', icon: Icons.support_agent_outlined, selectedIcon: Icons.support_agent, ), NavItem( label: 'Tasks', route: '/tasks', icon: Icons.task_outlined, selectedIcon: Icons.task, ), NavItem( label: 'IT Service Requests', route: '/it-service-requests', icon: Icons.miscellaneous_services_outlined, selectedIcon: Icons.miscellaneous_services, ), ]; } List _primaryItemsForRole(String role) { if (role == 'admin' || role == 'programmer') { return [ NavItem( label: 'Dashboard', route: '/dashboard', icon: Icons.grid_view_outlined, selectedIcon: Icons.grid_view_rounded, ), NavItem( label: 'Tickets', route: '/tickets', icon: Icons.support_agent_outlined, selectedIcon: Icons.support_agent, ), NavItem( label: 'Tasks', route: '/tasks', icon: Icons.task_outlined, selectedIcon: Icons.task, ), NavItem( label: 'Workforce', route: '/workforce', icon: Icons.groups_outlined, selectedIcon: Icons.groups, ), NavItem( label: 'Reports', route: '/reports', icon: Icons.analytics_outlined, selectedIcon: Icons.analytics, ), ]; } return _standardNavItems(); } List _mobileNavItems(List primary, List overflow) { if (overflow.isEmpty) { return primary; } return [ ...primary, NavItem(label: 'More', route: '', icon: Icons.more_horiz, isOverflow: true), ]; } List _mobilePrimaryItems(List primary) { if (primary.length <= 4) { return primary; } return primary.take(4).toList(); } List _flattenSections(List sections) { return [for (final section in sections) ...section.items]; } List _overflowItems(List all, List primary) { final primaryRoutes = primary.map((item) => item.route).toSet(); return all .where( (item) => !item.isLogout && item.route.isNotEmpty && !primaryRoutes.contains(item.route), ) .toList(); } Future _showOverflowSheet( BuildContext context, List items, VoidCallback onLogout, ) async { final parentContext = context; await m3ShowBottomSheet( context: context, builder: (context) { return SafeArea( child: ListView( padding: const EdgeInsets.all(16), children: [ for (final item in items) ListTile( leading: Icon(item.icon), title: Text(item.label), onTap: () { Navigator.of(context).pop(); item.onTap(context, onLogout: onLogout); }, ), if (!kIsWeb) ...[ const Divider(height: 16), ListTile( leading: const Icon(Icons.offline_bolt_rounded), title: const Text('Offline Readiness'), subtitle: const Text('Cache status & pending sync'), onTap: () { Navigator.of(context).pop(); showOfflineReadinessSheet(parentContext); }, ), ], ], ), ); }, ); } bool _isSelected(String location, String route) { if (route.isEmpty) return false; if (location == route) return true; return location.startsWith('$route/'); } int _currentIndex(String location, List items) { final index = items.indexWhere((item) => _isSelected(location, item.route)); if (index != -1) return index; final overflowIndex = items.indexWhere((item) => item.isOverflow); return overflowIndex == -1 ? 0 : overflowIndex; } String _routeToTitle(String location) { final path = location.split('?').first; return switch (path) { '/dashboard' => 'Dashboard', '/attendance' => 'Attendance', '/tickets' => 'Tickets', '/tasks' => 'Tasks', '/it-service-requests' => 'IT Service Requests', '/network-map' => 'Network Map', '/notifications' => 'Notifications', '/whereabouts' => 'Whereabouts', '/workforce' => 'Workforce', '/reports' => 'Reports', '/announcements' => 'Announcements', '/profile' => 'My Profile', '/settings/users' => 'Users', '/settings/offices' => 'Offices', '/settings/teams' => 'IT Teams', _ => '', }; } // ── Adaptive AppBar title ──────────────────────────────────────────────────── class _AppBarTitle extends StatelessWidget { const _AppBarTitle({ required this.location, required this.showRail, required this.isExtended, }); final String location; final bool showRail; final bool isExtended; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final tt = Theme.of(context).textTheme; final pageTitle = _routeToTitle(location); // Shared page-name suffix: divider line + title text List pageSuffix = pageTitle.isEmpty ? [] : [ const SizedBox(width: 12), Container(width: 1, height: 28, color: cs.outlineVariant), const SizedBox(width: 12), Text( pageTitle, style: tt.titleMedium?.copyWith( color: cs.onSurfaceVariant, fontWeight: FontWeight.w500, ), ), ]; // Mobile: compact logo + brand name, no page title if (!showRail) { return Row(mainAxisSize: MainAxisSize.min, children: [ Image.asset('assets/tasq_ico.png', width: 28, height: 28), const SizedBox(width: 8), const Text('TasQ'), ]); } // Desktop (extended rail already shows brand): logo + page name only if (isExtended) { return Row(mainAxisSize: MainAxisSize.min, children: [ Image.asset('assets/tasq_ico.png', width: 28, height: 28), ...pageSuffix, ]); } // Tablet (collapsed rail): logo + brand + page name return Row(mainAxisSize: MainAxisSize.min, children: [ Image.asset('assets/tasq_ico.png', width: 28, height: 28), const SizedBox(width: 8), const Text('TasQ'), ...pageSuffix, ]); } } // ── M3 profile menu anchor ─────────────────────────────────────────────────── class _ProfileMenuAnchor extends StatelessWidget { const _ProfileMenuAnchor({ required this.isStandard, required this.displayName, required this.avatarUrl, required this.onProfile, required this.onSignOut, }); final bool isStandard; final String displayName; final String? avatarUrl; final VoidCallback onProfile; final VoidCallback onSignOut; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final tt = Theme.of(context).textTheme; return MenuAnchor( menuChildren: [ MenuItemButton( leadingIcon: const Icon(Icons.person_outline), onPressed: onProfile, child: const Text('My Profile'), ), MenuItemButton( leadingIcon: const Icon(Icons.logout), onPressed: onSignOut, child: const Text('Sign out'), ), ], builder: (context, controller, _) { void toggle() => controller.isOpen ? controller.close() : controller.open(); if (isStandard) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), child: InkWell( onTap: toggle, borderRadius: BorderRadius.circular(24), child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( color: cs.secondaryContainer, borderRadius: BorderRadius.circular(24), ), child: Row(mainAxisSize: MainAxisSize.min, children: [ ProfileAvatar( fullName: displayName, avatarUrl: avatarUrl, radius: 14, heroTag: 'profile-avatar', ), const SizedBox(width: 8), Text( displayName, style: tt.labelLarge?.copyWith( color: cs.onSecondaryContainer, fontWeight: FontWeight.w600, ), ), const SizedBox(width: 4), Icon(Icons.expand_more, size: 18, color: cs.onSecondaryContainer), ]), ), ), ); } // Admin / non-standard: circular avatar icon button return Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: IconButton( tooltip: 'Account', onPressed: toggle, icon: ProfileAvatar( fullName: displayName, avatarUrl: avatarUrl, radius: 16, heroTag: 'profile-avatar', ), ), ); }, ); } }