import 'dart:async'; import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; import 'package:mileograph_flutter/components/pages/calculator.dart'; import 'package:mileograph_flutter/components/pages/calculator_details.dart'; import 'package:mileograph_flutter/components/login/login.dart'; import 'package:mileograph_flutter/components/pages/dashboard.dart'; import 'package:mileograph_flutter/components/pages/loco_legs.dart'; import 'package:mileograph_flutter/components/pages/loco_timeline.dart'; import 'package:mileograph_flutter/components/pages/logbook.dart'; import 'package:mileograph_flutter/components/pages/more.dart'; import 'package:mileograph_flutter/components/pages/badges.dart'; import 'package:mileograph_flutter/components/pages/new_entry.dart'; import 'package:mileograph_flutter/components/pages/new_traction.dart'; import 'package:mileograph_flutter/components/pages/profile.dart'; import 'package:mileograph_flutter/components/pages/settings.dart'; import 'package:mileograph_flutter/components/pages/stats.dart'; import 'package:mileograph_flutter/components/pages/traction.dart'; import 'package:mileograph_flutter/components/widgets/friend_request_notification_card.dart'; import 'package:mileograph_flutter/components/widgets/leg_share_notification_card.dart'; import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/services/authservice.dart'; import 'package:mileograph_flutter/services/data_service.dart'; import 'package:mileograph_flutter/services/navigation_guard.dart'; import 'package:provider/provider.dart'; final GlobalKey _shellNavigatorKey = GlobalKey(); const List _contentPages = [ "/dashboard", "/calculator", "/logbook", "/traction", "/add", "/more", ]; const List _defaultTabDestinations = [ "/dashboard", "/calculator", "/logbook/entries", "/traction", "/add", "/more", ]; const int _addTabIndex = 4; class _NavItem { final String label; final IconData icon; const _NavItem(this.label, this.icon); } const List<_NavItem> _navItems = [ _NavItem("Home", Icons.home), _NavItem("Calculator", Icons.route), _NavItem("Logbook", Icons.menu_book), _NavItem("Traction", Icons.train), _NavItem("Add", Icons.add), _NavItem("More", Icons.more_horiz), ]; int tabIndexForPath(String path) { var matchPath = path; if (matchPath == '/') matchPath = '/dashboard'; if (matchPath.startsWith('/dashboard')) return 0; if (matchPath.startsWith('/legs')) { matchPath = '/logbook/entries'; } else if (matchPath.startsWith('/trips')) { matchPath = '/logbook/trips'; } if (matchPath.startsWith('/logbook')) { matchPath = '/logbook'; } else if (matchPath.startsWith('/profile') || matchPath.startsWith('/settings') || matchPath.startsWith('/more')) { matchPath = '/more'; } final newIndex = _contentPages.indexWhere( (routePath) => matchPath == routePath || matchPath.startsWith('$routePath/'), ); return newIndex < 0 ? 0 : newIndex; } class MyApp extends StatefulWidget { const MyApp({super.key}); @override State createState() => _MyAppState(); } class _MyAppState extends State { late final GoRouter _router; bool _routerInitialized = false; final ColorScheme defaultLight = ColorScheme.fromSeed(seedColor: Colors.red); final ColorScheme defaultDark = ColorScheme.fromSeed( seedColor: Colors.red, brightness: Brightness.dark, ); @override void didChangeDependencies() { super.didChangeDependencies(); if (_routerInitialized) return; _routerInitialized = true; final auth = context.read(); _router = GoRouter( initialLocation: '/dashboard', refreshListenable: auth, redirect: (context, state) { final loggedIn = auth.isLoggedIn; final loggingIn = state.uri.toString() == '/login'; final atSettings = state.uri.toString() == '/settings'; if (!loggedIn && !loggingIn && !atSettings) return '/login'; if (loggedIn && loggingIn) return '/dashboard'; return null; }, routes: [ GoRoute(path: '/', redirect: (context, state) => '/dashboard'), ShellRoute( navigatorKey: _shellNavigatorKey, builder: (context, state, child) => MyHomePage(child: child), routes: [ GoRoute( path: '/dashboard', builder: (context, state) => const Dashboard(), ), GoRoute( path: '/calculator', builder: (context, state) => const CalculatorPage(), routes: [ GoRoute( path: 'details', builder: (context, state) => CalculatorDetailsPage(result: state.extra), ), ], ), GoRoute( path: '/logbook', redirect: (context, state) => '/logbook/entries', ), GoRoute( path: '/logbook/entries', builder: (context, state) => const LogbookPage(), ), GoRoute( path: '/logbook/trips', builder: (context, state) => const LogbookPage(initialTab: LogbookTab.trips), ), GoRoute( path: '/trips', redirect: (context, state) => '/logbook/trips', ), GoRoute( path: '/legs', redirect: (context, state) => '/logbook/entries', ), GoRoute( path: '/traction', builder: (context, state) => TractionPage(), ), GoRoute( path: '/profile', builder: (context, state) => const ProfilePage(), ), GoRoute( path: '/traction/:id/timeline', builder: (_, state) { final idParam = state.pathParameters['id']; final locoId = int.tryParse(idParam ?? '') ?? 0; final extra = state.extra; String label = state.uri.queryParameters['label'] ?? ''; if (extra is Map && extra['label'] is String) { label = extra['label'] as String; } else if (extra is String && extra.isNotEmpty) { label = extra; } if (label.trim().isEmpty) label = 'Loco $locoId'; return LocoTimelinePage(locoId: locoId, locoLabel: label); }, ), GoRoute( path: '/traction/:id/legs', builder: (_, state) { final idParam = state.pathParameters['id']; final locoId = int.tryParse(idParam ?? '') ?? 0; final extra = state.extra; String label = state.uri.queryParameters['label'] ?? ''; if (extra is Map && extra['label'] is String) { label = extra['label'] as String; } else if (extra is String && extra.isNotEmpty) { label = extra; } if (label.trim().isEmpty) label = 'Loco $locoId'; return LocoLegsPage(locoId: locoId, locoLabel: label); }, ), GoRoute( path: '/traction/new', builder: (context, state) => const NewTractionPage(), ), GoRoute( path: '/add', builder: (context, state) { final extra = state.extra; return NewEntryPage( legShare: extra is LegShareData ? extra : null, ); }, ), GoRoute( path: '/more', builder: (context, state) => const MorePage(), ), GoRoute( path: '/more/profile', builder: (context, state) => const ProfilePage(), ), GoRoute( path: '/more/badges', builder: (context, state) => const BadgesPage(), ), GoRoute( path: '/more/stats', builder: (context, state) => const StatsPage(), ), GoRoute( path: '/more/settings', builder: (context, state) => const SettingsPage(), ), GoRoute( path: '/more/admin', builder: (context, state) => const AdminPage(), ), GoRoute( path: '/legs/edit/:id', builder: (_, state) { final idParam = state.pathParameters['id']; final legId = idParam == null ? null : int.tryParse(idParam); return NewEntryPage(editLegId: legId); }, ), ], ), GoRoute( path: '/login', builder: (context, state) => const LoginScreen(), ), GoRoute( path: '/settings', builder: (context, state) => const SettingsPage(), ), ], ); } @override Widget build(BuildContext context) { return DynamicColorBuilder( builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) { return MaterialApp.router( title: 'Mileograph', routerConfig: _router, theme: ThemeData( useMaterial3: true, colorScheme: lightDynamic ?? defaultLight, ), darkTheme: ThemeData( useMaterial3: true, colorScheme: darkDynamic ?? defaultDark, ), themeMode: ThemeMode.system, ); }, ); } } class _BackIntent extends Intent { const _BackIntent(); } class _ForwardIntent extends Intent { const _ForwardIntent(); } class MyHomePage extends StatefulWidget { final Widget child; const MyHomePage({super.key, required this.child}); @override State createState() => _MyHomePageState(); } class _MyHomePageState extends State { List get tabDestinations => _defaultTabDestinations; Future _onItemTapped(int index, int currentIndex) async { if (index < 0 || index >= tabDestinations.length) { return; } final currentPath = GoRouterState.of(context).uri.path; final targetPath = tabDestinations[index]; final alreadyAtTarget = currentPath == targetPath || currentPath.startsWith('$targetPath/'); if (index == currentIndex && alreadyAtTarget) return; await NavigationGuard.attemptNavigation(() async { if (!mounted) return; _navigateToIndex(index); }); } final List _history = []; int _historyPosition = -1; final List _forwardHistory = []; bool _suppressRecord = false; bool _fetched = false; bool _railCollapsed = false; Timer? _notificationsTimer; @override void didChangeDependencies() { super.didChangeDependencies(); if (_fetched) return; _fetched = true; WidgetsBinding.instance.addPostFrameCallback((_) { Future(() async { if (!mounted) return; final data = context.read(); final auth = context.read(); await auth.tryRestoreSession(); if (!auth.isLoggedIn) return; data.fetchEventFields(); if (data.homepageStats == null) { data.fetchHomepageStats(); } if (data.legs.isEmpty) { data.fetchLegs(); } if (data.traction.isEmpty) { data.fetchHadTraction(); } if (data.latestLocoChanges.isEmpty) { data.fetchLatestLocoChanges(); } if (data.onThisDay.isEmpty) { data.fetchOnThisDay(); } if (data.tripDetails.isEmpty) { data.fetchTripDetails(); } if (data.notifications.isEmpty) { data.fetchNotifications(); } if (data.friendships.isEmpty && !data.isFriendshipsLoading) { data.fetchFriendships(); } if ((data.pendingIncoming.isEmpty && data.pendingOutgoing.isEmpty) && !data.isPendingFriendshipsLoading) { data.fetchPendingFriendships(); } _startNotificationPolling(); }); }); } void _startNotificationPolling() { _notificationsTimer?.cancel(); final auth = context.read(); if (!auth.isLoggedIn) return; _notificationsTimer = Timer.periodic(const Duration(minutes: 2), (_) async { if (!mounted) return; final auth = context.read(); if (!auth.isLoggedIn) return; final data = context.read(); try { await data.fetchNotifications(); } catch (_) { // Errors already logged inside data service. } }); } @override Widget build(BuildContext context) { final uri = GoRouterState.of(context).uri; final pageIndex = tabIndexForPath(uri.path); _syncHistory(uri.path); if (pageIndex != _addTabIndex) { NavigationGuard.unregister(); } final homepageReady = context.select( (data) => data.homepageStats != null || !data.isHomepageLoading, ); final data = context.watch(); final auth = context.read(); final currentPage = homepageReady ? widget.child : const Center(child: CircularProgressIndicator()); final scaffold = LayoutBuilder( builder: (context, constraints) { final isWide = constraints.maxWidth >= 900; final defaultRailExtended = constraints.maxWidth >= 1400; final railExtended = defaultRailExtended && !_railCollapsed; final showRailToggle = defaultRailExtended; final navRailDestinations = _navItems .map( (item) => NavigationRailDestination( icon: Icon(item.icon), label: Text(item.label), ), ) .toList(); final navBarDestinations = _navItems .map( (item) => NavigationDestination( icon: Icon(item.icon), label: item.label, ), ) .toList(); return Scaffold( appBar: AppBar( backgroundColor: Theme.of(context).colorScheme.inversePrimary, title: Text.rich( TextSpan( children: const [ TextSpan(text: "Mile"), TextSpan( text: "O", style: TextStyle(color: Colors.red), ), TextSpan(text: "graph"), ], style: const TextStyle( decoration: TextDecoration.none, color: Colors.white, fontFamily: "Tomatoes", ), ), ), actions: [ _buildNotificationAction(context, data), IconButton( tooltip: 'Profile', onPressed: () => context.go('/more/profile'), icon: const Icon(Icons.person), ), IconButton( tooltip: 'Settings', onPressed: () => context.go('/more/settings'), icon: const Icon(Icons.settings), ), IconButton( onPressed: auth.logout, icon: const Icon(Icons.logout), ), ], ), bottomNavigationBar: isWide ? null : NavigationBar( selectedIndex: pageIndex, onDestinationSelected: (int index) => _onItemTapped(index, pageIndex), destinations: navBarDestinations, ), body: isWide ? Row( children: [ SafeArea( child: LayoutBuilder( builder: (ctx, _) { return Stack( children: [ Padding( padding: EdgeInsets.only( bottom: showRailToggle ? 56.0 : 0.0, ), child: NavigationRail( selectedIndex: pageIndex, extended: railExtended, labelType: railExtended ? NavigationRailLabelType.none : NavigationRailLabelType.selected, onDestinationSelected: (int index) => _onItemTapped(index, pageIndex), destinations: navRailDestinations, ), ), if (showRailToggle) Positioned( left: 0, right: 0, bottom: 8, child: _buildRailToggleButton(railExtended), ), ], ); }, ), ), const VerticalDivider(width: 1), Expanded(child: currentPage), ], ) : currentPage, ); }, ); return Shortcuts( shortcuts: { LogicalKeySet(LogicalKeyboardKey.browserBack): const _BackIntent(), LogicalKeySet(LogicalKeyboardKey.browserForward): const _ForwardIntent(), }, child: Actions( actions: { _BackIntent: CallbackAction<_BackIntent>( onInvoke: (_) { _handleBackNavigation(allowExit: false, recordForward: true); return null; }, ), _ForwardIntent: CallbackAction<_ForwardIntent>( onInvoke: (_) { _handleForwardNavigation(); return null; }, ), }, child: Focus( autofocus: true, child: Listener( onPointerDown: _handlePointerButtons, behavior: HitTestBehavior.opaque, child: PopScope( canPop: false, onPopInvokedWithResult: (didPop, _) async { if (didPop) return; await _handleBackNavigation( allowExit: true, recordForward: false, ); }, child: scaffold, ), ), ), ), ); } void _handlePointerButtons(PointerDownEvent event) { // Support mouse back/forward buttons. if (event.buttons == kBackMouseButton) { _handleBackNavigation(allowExit: false, recordForward: true); } else if (event.buttons == kForwardMouseButton) { _handleForwardNavigation(); } } Widget _buildRailToggleButton(bool railExtended) { final collapseIcon = railExtended ? Icons.chevron_left : Icons.chevron_right; final collapseLabel = railExtended ? 'Collapse' : 'Expand'; if (railExtended) { return Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), child: TextButton.icon( onPressed: () => setState(() => _railCollapsed = !_railCollapsed), icon: Icon(collapseIcon), label: Text(collapseLabel), ), ); } return Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), child: IconButton( icon: Icon(collapseIcon), tooltip: collapseLabel, onPressed: () => setState(() => _railCollapsed = !_railCollapsed), ), ); } Widget _buildNotificationAction(BuildContext context, DataService data) { final count = data.notifications.length; final hasBadge = count > 0; final badgeText = count > 9 ? '9+' : '$count'; final isLoading = data.isNotificationsLoading; return Stack( clipBehavior: Clip.none, children: [ IconButton( tooltip: 'Notifications', onPressed: () => _openNotificationsPanel(context), icon: isLoading ? const SizedBox( width: 22, height: 22, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.notifications_none), ), if (hasBadge) Positioned( right: 6, top: 8, child: IgnorePointer(child: _buildBadge(badgeText)), ), ], ); } Future _openNotificationsPanel(BuildContext context) async { final data = context.read(); final isWide = MediaQuery.sizeOf(context).width >= 900; final sheetHeight = MediaQuery.sizeOf(context).height * 0.9; try { await data.fetchNotifications(); } catch (_) { // Already logged inside data service. } if (!context.mounted) return; if (isWide) { await showDialog( context: context, builder: (dialogCtx) => Dialog( insetPadding: const EdgeInsets.all(16), child: _buildNotificationsContent(dialogCtx, isWide), ), ); } else { await showModalBottomSheet( context: context, isScrollControlled: true, builder: (sheetCtx) { return SizedBox( height: sheetHeight, child: SafeArea( child: _buildNotificationsContent(sheetCtx, isWide), ), ); }, ); } } Widget _buildNotificationsContent(BuildContext context, bool isWide) { final data = context.watch(); final notifications = data.notifications; final dismissibleIds = notifications .where( (n) => !_isFriendRequestNotification(n) && !_isLegShareNotification(n), ) .map((e) => e.id) .toList(); final loading = data.isNotificationsLoading; final listHeight = isWide ? 380.0 : MediaQuery.of(context).size.height * 0.6; Widget body; if (loading && notifications.isEmpty) { body = const Center( child: Padding( padding: EdgeInsets.symmetric(vertical: 24.0), child: CircularProgressIndicator(), ), ); } else if (notifications.isEmpty) { body = const Padding( padding: EdgeInsets.symmetric(vertical: 12.0), child: Text('No notifications right now.'), ); } else { body = SizedBox( height: listHeight, child: ListView.separated( itemCount: notifications.length, separatorBuilder: (_, index) => const SizedBox(height: 8), itemBuilder: (ctx, index) { final item = notifications[index]; final isFriendRequest = _isFriendRequestNotification(item); final isLegShare = _isLegShareNotification(item); final isSpecial = isFriendRequest || isLegShare; return Card( child: Padding( padding: const EdgeInsets.all(12.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( item.title.isNotEmpty ? item.title : 'Notification', style: Theme.of(context).textTheme.titleMedium ?.copyWith(fontWeight: FontWeight.w700), ), const SizedBox(height: 4), Text( isFriendRequest || isLegShare ? isFriendRequest ? 'Accept to share entries' : 'Shared entry details below.' : item.body, style: Theme.of(context).textTheme.bodyMedium, ), if (item.createdAt != null) ...[ const SizedBox(height: 6), Text( _formatNotificationTime(item.createdAt!), style: Theme.of(context).textTheme.bodySmall ?.copyWith( color: () { final baseColor = Theme.of( context, ).textTheme.bodySmall?.color; if (baseColor == null) return null; final newAlpha = (baseColor.a * 0.7) .clamp(0.0, 1.0); return baseColor.withValues( alpha: newAlpha, ); }(), ), ), ], ], ), ), if (!isSpecial) ...[ const SizedBox(width: 8), TextButton( onPressed: () => _dismissNotifications(context, [item.id]), child: const Text('Dismiss'), ), ], ], ), if (isFriendRequest) Padding( padding: const EdgeInsets.only(top: 8.0), child: FriendRequestNotificationCard( notification: item, ), ), if (isLegShare) Padding( padding: const EdgeInsets.only(top: 8.0), child: LegShareNotificationCard(notification: item), ), ], ), ), ); }, ), ); } return SizedBox( width: isWide ? 420 : double.infinity, child: Padding( padding: const EdgeInsets.all(16.0), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text( 'Notifications', style: Theme.of( context, ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), ), const Spacer(), TextButton( onPressed: notifications.isEmpty ? null : () => _dismissNotifications(context, dismissibleIds), child: const Text('Dismiss all'), ), ], ), const SizedBox(height: 12), body, ], ), ), ); } Future _dismissNotifications( BuildContext context, List ids, ) async { if (ids.isEmpty) return; final messenger = ScaffoldMessenger.maybeOf(context); try { await context.read().dismissNotifications(ids); } catch (e) { messenger?.showSnackBar(SnackBar(content: Text('Failed to dismiss: $e'))); } } String _formatNotificationTime(DateTime dateTime) { final y = dateTime.year.toString().padLeft(4, '0'); final m = dateTime.month.toString().padLeft(2, '0'); final d = dateTime.day.toString().padLeft(2, '0'); final hh = dateTime.hour.toString().padLeft(2, '0'); final mm = dateTime.minute.toString().padLeft(2, '0'); return '$y-$m-$d $hh:$mm'; } bool _isFriendRequestNotification(UserNotification notification) { final type = notification.type.trim().toLowerCase(); final channel = notification.channel.trim().toLowerCase(); final title = notification.title.trim().toLowerCase(); bool matchesChannel = channel == 'friend_request' || channel == 'friend-request'; if (!matchesChannel) { matchesChannel = channel.contains('friend_request') || channel.contains('friend-request') || channel.contains('friend'); } return matchesChannel || type == 'friend_request' || type == 'friend-request' || title.contains('friend request'); } bool _isLegShareNotification(UserNotification notification) { final channel = notification.channel.trim().toLowerCase(); final type = notification.type.trim().toLowerCase(); return channel.contains('leg_share') || type.contains('leg_share'); } Widget _buildBadge(String label) { return Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: Colors.redAccent, borderRadius: BorderRadius.circular(10), ), constraints: const BoxConstraints(minWidth: 20), child: Text( label, style: const TextStyle( color: Colors.white, fontSize: 11, fontWeight: FontWeight.bold, ), textAlign: TextAlign.center, ), ); } Future _handleBackNavigation({ bool allowExit = false, bool recordForward = false, }) async { final currentPath = GoRouterState.of(context).uri.path; final shellNav = _shellNavigatorKey.currentState; if (shellNav != null && shellNav.canPop()) { if (recordForward) _pushForward(currentPath); _alignHistoryAfterPop(currentPath); shellNav.pop(); return true; } if (_historyPosition > 0) { if (recordForward) _pushForward(currentPath); _historyPosition -= 1; _suppressRecord = true; context.go(_history[_historyPosition]); return true; } final homePath = tabDestinations.first; if (currentPath != homePath) { if (recordForward) _pushForward(currentPath); _suppressRecord = true; context.go(homePath); return true; } if (allowExit) { SystemNavigator.pop(); return true; } return false; } Future _handleForwardNavigation() async { if (_forwardHistory.isEmpty) return false; final nextPath = _forwardHistory.removeLast(); // Move cursor forward, keeping history in sync. if (_historyPosition < _history.length - 1) { _historyPosition += 1; _history[_historyPosition] = nextPath; if (_historyPosition < _history.length - 1) { _history.removeRange(_historyPosition + 1, _history.length); } } else { _history.add(nextPath); _historyPosition = _history.length - 1; } _suppressRecord = true; if (!mounted) return false; context.go(nextPath); return true; } void _pushForward(String path) { if (_forwardHistory.isEmpty || _forwardHistory.last != path) { _forwardHistory.add(path); } } void _alignHistoryAfterPop(String currentPath) { if (_history.isEmpty) return; if (_historyPosition >= 0 && _historyPosition < _history.length && _history[_historyPosition] == currentPath) { if (_historyPosition > 0) { _historyPosition -= 1; } _history.removeRange(_historyPosition + 1, _history.length); _suppressRecord = true; } } void _syncHistory(String path) { if (_history.isEmpty) { _history.add(path); _historyPosition = 0; return; } if (_suppressRecord) { _suppressRecord = false; return; } if (_historyPosition >= 0 && _historyPosition < _history.length && _history[_historyPosition] == path) { return; } if (_historyPosition < _history.length - 1) { _history.removeRange(_historyPosition + 1, _history.length); } _history.add(path); _historyPosition = _history.length - 1; _forwardHistory.clear(); } void _navigateToIndex(int index) { _suppressRecord = false; _forwardHistory.clear(); context.go(tabDestinations[index]); } @override void dispose() { _notificationsTimer?.cancel(); super.dispose(); } }