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/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/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/traction.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", "/logbook/entries", "/traction", "/add", "/more", ]; const int _addTabIndex = 5; class _NavItem { final String label; final IconData icon; const _NavItem(this.label, this.icon); } const List<_NavItem> _navItems = [ _NavItem("Home", Icons.home), _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'; } else if (matchPath == '/logbook') { matchPath = '/logbook/entries'; } else if (matchPath.startsWith('/logbook/trips')) { matchPath = '/logbook/entries'; } 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: (_, __) => '/dashboard', ), ShellRoute( navigatorKey: _shellNavigatorKey, builder: (context, state, child) => MyHomePage(child: child), routes: [ GoRoute( path: '/dashboard', builder: (context, state) => const Dashboard(), ), GoRoute( path: '/logbook', builder: (context, state) => const LogbookPage(), ), GoRoute( path: '/logbook/entries', builder: (context, state) => const LogbookPage(), ), GoRoute( path: '/logbook/trips', builder: (context, state) => const LogbookPage(initialTab: LogbookTab.trips), ), GoRoute( path: '/trips', builder: (context, state) => const LogbookPage(initialTab: LogbookTab.trips), ), GoRoute( path: '/legs', builder: (context, state) => const LogbookPage(), ), 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) => NewEntryPage()), GoRoute( path: '/more', builder: (context, state) => const MorePage(), ), GoRoute( path: '/more/profile', builder: (context, state) => const ProfilePage(), ), GoRoute( path: '/more/settings', builder: (context, state) => const SettingsPage(), ), 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 contentPages => _contentPages; Future _onItemTapped(int index, int currentIndex) async { if (index < 0 || index >= contentPages.length) { return; } final currentPath = GoRouterState.of(context).uri.path; final targetPath = contentPages[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; @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(); } }); }); } @override Widget build(BuildContext context) { final uri = GoRouterState.of(context).uri; final pageIndex = tabIndexForPath(uri.path); _syncHistory(pageIndex); 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: '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(); try { await data.fetchNotifications(); } catch (_) { // Already logged inside data service. } if (!mounted) return; final isWide = MediaQuery.of(context).size.width >= 900; final panelBuilder = (BuildContext ctx) { return _buildNotificationsContent(ctx, isWide); }; if (isWide) { await showDialog( context: context, builder: (dialogCtx) => Dialog( insetPadding: const EdgeInsets.all(16), child: panelBuilder(dialogCtx), ), ); } else { await showModalBottomSheet( context: context, isScrollControlled: true, builder: (sheetCtx) { final height = MediaQuery.of(context).size.height * 0.9; return SizedBox( height: height, child: SafeArea( child: panelBuilder(sheetCtx), ), ); }, ); } } Widget _buildNotificationsContent(BuildContext context, bool isWide) { final data = context.watch(); final notifications = data.notifications; 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: (_, __) => const SizedBox(height: 8), itemBuilder: (ctx, index) { final item = notifications[index]; 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( 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: Theme.of(context) .textTheme .bodySmall ?.color ?.withOpacity(0.7), ), ), ], ], ), ), const SizedBox(width: 8), TextButton( onPressed: () => _dismissNotifications( context, [item.id], ), child: const Text('Dismiss'), ), ], ), ], ), ), ); }, ), ); } 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, notifications.map((e) => e.id).toList(), ), 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'; } 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, ), ); } int get _currentPageIndex => tabIndexForPath(GoRouterState.of(context).uri.path); Future _handleBackNavigation({ bool allowExit = false, bool recordForward = false, }) async { final pageIndex = _currentPageIndex; final shellNav = _shellNavigatorKey.currentState; if (shellNav != null && shellNav.canPop()) { shellNav.pop(); return true; } if (_historyPosition > 0) { if (recordForward) _pushForward(pageIndex); _historyPosition -= 1; _suppressRecord = true; context.go(contentPages[_history[_historyPosition]]); return true; } if (pageIndex != 0) { if (recordForward) _pushForward(pageIndex); _suppressRecord = true; context.go(contentPages[0]); return true; } if (allowExit) { SystemNavigator.pop(); return true; } return false; } Future _handleForwardNavigation() async { if (_forwardHistory.isEmpty) return false; final nextTab = _forwardHistory.removeLast(); // Move cursor forward, keeping history in sync. if (_historyPosition < _history.length - 1) { _historyPosition += 1; _history[_historyPosition] = nextTab; if (_historyPosition < _history.length - 1) { _history.removeRange(_historyPosition + 1, _history.length); } } else { _history.add(nextTab); _historyPosition = _history.length - 1; } _suppressRecord = true; if (!mounted) return false; context.go(contentPages[nextTab]); return true; } void _pushForward(int pageIndex) { if (_forwardHistory.isEmpty || _forwardHistory.last != pageIndex) { _forwardHistory.add(pageIndex); } } void _syncHistory(int pageIndex) { if (_history.isEmpty) { _history.add(pageIndex); _historyPosition = 0; return; } if (_suppressRecord) { _suppressRecord = false; return; } if (_historyPosition >= 0 && _historyPosition < _history.length && _history[_historyPosition] == pageIndex) { return; } if (_historyPosition < _history.length - 1) { _history.removeRange(_historyPosition + 1, _history.length); } _history.add(pageIndex); _historyPosition = _history.length - 1; _forwardHistory.clear(); } void _navigateToIndex(int index) { _suppressRecord = false; _forwardHistory.clear(); context.go(contentPages[index]); } }