import 'dart:async'; import 'dart:math'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import 'package:mileograph_flutter/components/dashboard/latest_loco_changes_panel.dart'; import 'package:mileograph_flutter/components/dashboard/leaderboard_panel.dart'; import 'package:mileograph_flutter/components/dashboard/top_traction_panel.dart'; import 'package:mileograph_flutter/components/widgets/animated_count_text.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/distance_unit_service.dart'; import 'package:provider/provider.dart'; class Dashboard extends StatefulWidget { const Dashboard({super.key}); @override State createState() => _DashboardState(); } class _DashboardState extends State { bool _showAllOnThisDay = false; bool _isCurrent = false; Timer? _carouselTimer; int _carouselIndex = 0; int _carouselItemCount = 0; final Random _carouselRandom = Random(); List _carouselItems = const []; String _carouselSignature = ''; @override void initState() { super.initState(); } @override Widget build(BuildContext context) { final data = context.watch(); final auth = context.watch(); final distanceUnits = context.watch(); final stats = data.homepageStats; final isInitialLoading = data.isHomepageLoading && stats == null; return RefreshIndicator( onRefresh: () async { await _refreshDashboardData(force: true); }, child: LayoutBuilder( builder: (context, constraints) { _handleRouteFocus(); const spacing = 16.0; final maxWidth = constraints.maxWidth; return Stack( children: [ ListView( padding: const EdgeInsets.all(16), children: [ _buildHero(context, auth, data, stats, distanceUnits), const SizedBox(height: spacing), _buildTiles( context, data, distanceUnits, maxWidth, spacing, ), ], ), if (isInitialLoading) Positioned.fill( child: Container( color: Theme.of( context, ).colorScheme.surface.withValues(alpha: 0.7), child: const Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ CircularProgressIndicator(), SizedBox(height: 12), Text('Loading dashboard data...'), ], ), ), ), ), ], ); }, ), ); } Widget _buildHero( BuildContext context, AuthService auth, DataService data, HomepageStats? stats, DistanceUnitService distanceUnits, ) { final colorScheme = Theme.of(context).colorScheme; final greetingName = stats?.user?.fullName ?? auth.fullName ?? auth.username ?? 'there'; final totalMileage = stats?.totalMileage ?? 0; final currentYearMileage = data.getMileageForCurrentYear(); final legCount = stats?.legCount ?? data.trips.length; return Card( clipBehavior: Clip.antiAlias, elevation: 2, child: Container( decoration: BoxDecoration( gradient: LinearGradient( colors: [ colorScheme.primaryContainer, colorScheme.secondaryContainer, ], begin: Alignment.topLeft, end: Alignment.bottomRight, ), ), padding: const EdgeInsets.all(18), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _heroHeading(context, greetingName, colorScheme), const SizedBox(height: 18), Wrap( spacing: 12, runSpacing: 12, children: [ _animatedMetricTile( context, label: 'Total mileage', value: totalMileage.toDouble(), formatter: (val) => distanceUnits.format(val, decimals: 1), icon: Icons.route, color: colorScheme.onPrimaryContainer, ), _animatedMetricTile( context, label: 'This year', value: currentYearMileage.toDouble(), formatter: (val) => distanceUnits.format(val, decimals: 1), icon: Icons.calendar_today, color: colorScheme.onPrimaryContainer, ), _animatedMetricTile( context, label: 'Entries logged', value: legCount.toDouble(), formatter: (val) => val.round().toString(), icon: Icons.format_list_bulleted, color: colorScheme.onPrimaryContainer, ), ], ), const SizedBox(height: 16), _buildClassClearanceCarousel(context, data, colorScheme), ], ), ), ); } Widget _buildClassClearanceCarousel( BuildContext context, DataService data, ColorScheme colorScheme, ) { final items = data.classClearanceProgress; final loading = data.isClassClearanceProgressLoading; _refreshCarouselItems(items); _startCarouselIfNeeded(_carouselItems.length); if (loading && _carouselItems.isEmpty) { return const Padding( padding: EdgeInsets.symmetric(vertical: 8.0), child: Center( child: SizedBox( height: 16, width: 16, child: CircularProgressIndicator(strokeWidth: 2), ), ), ); } if (_carouselItems.isEmpty) { return Text( 'No class clearance progress yet.', style: Theme.of(context).textTheme.labelSmall?.copyWith( color: colorScheme.onPrimaryContainer.withValues(alpha: 0.8), ), ); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Class clearance (in progress)', style: Theme.of(context).textTheme.labelSmall?.copyWith( color: colorScheme.onPrimaryContainer.withValues(alpha: 0.8), ), ), const SizedBox(height: 6), SizedBox( height: 58, child: AnimatedSwitcher( duration: const Duration(milliseconds: 450), switchInCurve: Curves.easeOutCubic, switchOutCurve: Curves.easeInCubic, transitionBuilder: (child, animation) { final tween = Tween( begin: const Offset(0, 0.4), end: Offset.zero, ); return ClipRect( child: SlideTransition( position: animation.drive(tween), child: FadeTransition(opacity: animation, child: child), ), ); }, child: _buildClassClearanceSlide( context, _carouselItems[_carouselIndex % _carouselItems.length], colorScheme, key: ValueKey(_carouselIndex), ), ), ), ], ); } Widget _buildClassClearanceSlide( BuildContext context, ClassClearanceProgress progress, ColorScheme colorScheme, {Key? key} ) { final pct = progress.percentComplete.clamp(0, 100); final textTheme = Theme.of(context).textTheme; final ratio = progress.total == 0 ? 0.0 : (progress.completed / progress.total).clamp(0.0, 1.0); final activeRatio = progress.total == 0 ? 0.0 : (progress.activeTotal / progress.total).clamp(0.0, 1.0); return TweenAnimationBuilder( key: key ?? ValueKey(progress.className), tween: Tween(begin: 0, end: ratio), duration: const Duration(milliseconds: 1200), curve: Curves.easeOutCubic, builder: (context, value, child) { final ratioValue = ratio == 0 ? 0.0 : (value / ratio).clamp(0.0, 1.0); final animatedHad = (progress.completed * ratioValue).round(); final animatedActive = (progress.activeTotal * ratioValue).round(); final animatedActiveRatio = (activeRatio * ratioValue).clamp(0.0, activeRatio); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Text( progress.className, style: textTheme.labelLarge?.copyWith( color: colorScheme.onPrimaryContainer, fontWeight: FontWeight.w700, ), overflow: TextOverflow.ellipsis, ), ), const SizedBox(width: 8), Text( '${pct.toStringAsFixed(0)}% • $animatedHad/$animatedActive/${progress.total}', style: textTheme.labelSmall?.copyWith( color: colorScheme.onPrimaryContainer .withValues(alpha: 0.8), ), ), ], ), const SizedBox(height: 6), ClipRRect( borderRadius: BorderRadius.circular(10), child: SizedBox( height: 8, child: Stack( children: [ Container( color: colorScheme.onPrimaryContainer.withValues( alpha: 0.2, ), ), FractionallySizedBox( alignment: Alignment.centerLeft, widthFactor: animatedActiveRatio.isNaN ? 0 : animatedActiveRatio, child: Container( color: colorScheme.onPrimaryContainer.withValues(alpha: 0.5), ), ), FractionallySizedBox( alignment: Alignment.centerLeft, widthFactor: value.isNaN ? 0 : value, child: Container( color: colorScheme.onPrimaryContainer, ), ), ], ), ), ), ], ); }, ); } void _startCarouselIfNeeded(int count) { if (count <= 1) { _stopCarousel(); return; } if (_carouselItemCount != count) { _carouselItemCount = count; _carouselIndex = 0; } if (_carouselTimer != null) return; _carouselTimer = Timer.periodic(const Duration(seconds: 8), (_) { if (!mounted || _carouselItemCount == 0) return; setState(() { _carouselIndex = (_carouselIndex + 1) % _carouselItemCount; }); }); } void _stopCarousel() { _carouselTimer?.cancel(); _carouselTimer = null; } void _refreshCarouselItems(List items) { final signature = items .map((item) => '${item.className}:${item.completed}:${item.activeTotal}:${item.total}') .join('|'); if (signature == _carouselSignature) return; _carouselSignature = signature; _carouselItems = List.from(items) ..shuffle(_carouselRandom); } @override void dispose() { _stopCarousel(); super.dispose(); } Widget _animatedMetricTile( BuildContext context, { required String label, required double value, required String Function(double) formatter, required IconData icon, required Color color, }) { final bg = Colors.white.withValues(alpha: 0.14); return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( color: bg, borderRadius: BorderRadius.circular(12), border: Border.all(color: Colors.white.withValues(alpha: 0.18)), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, color: color), const SizedBox(width: 10), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( label, style: Theme.of(context).textTheme.labelSmall?.copyWith( color: color.withValues(alpha: 0.85), letterSpacing: 0.4, ), ), AnimatedCountText( value: value, formatter: formatter, style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w800, color: color, ), ), ], ), ], ), ); } Widget _buildTiles( BuildContext context, DataService data, DistanceUnitService distanceUnits, double maxWidth, double spacing, ) { final isWide = maxWidth >= 1200; if (isWide) { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( flex: 2, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _buildOnThisDayCard(context, data, distanceUnits), const SizedBox(height: 16), _buildTripsCard(context, data, distanceUnits), const SizedBox(height: 16), const LatestLocoChangesPanel(expanded: true), ], ), ), const SizedBox(width: 16), Expanded( flex: 1, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: const [ TopTractionPanel(), SizedBox(height: 16), LeaderboardPanel(), ], ), ), ], ); } return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _buildOnThisDayCard(context, data, distanceUnits), const SizedBox(height: 16), const TopTractionPanel(), const SizedBox(height: 16), const LeaderboardPanel(), const SizedBox(height: 16), _buildTripsCard(context, data, distanceUnits), const SizedBox(height: 16), const LatestLocoChangesPanel(), ], ); } Widget _heroHeading( BuildContext context, String greetingName, ColorScheme colorScheme, ) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Dashboard', style: Theme.of(context).textTheme.labelMedium?.copyWith( color: colorScheme.onPrimaryContainer, letterSpacing: 0.4, ), ), const SizedBox(height: 2), Text( 'Welcome back, $greetingName', style: Theme.of(context).textTheme.headlineSmall?.copyWith( color: colorScheme.onPrimaryContainer, fontWeight: FontWeight.w800, ), ), ], ); } Widget _buildOnThisDayCard( BuildContext context, DataService data, DistanceUnitService distanceUnits) { final filtered = data.onThisDay .where((leg) => leg.beginTime.year != DateTime.now().year) .toList(); final textTheme = Theme.of(context).textTheme; final showMore = filtered.length > 5; final visible = _showAllOnThisDay ? filtered : filtered.take(6).toList(); return _panel( context, icon: Icons.history_toggle_off, title: 'On this day', trailing: data.isOnThisDayLoading ? const SizedBox( height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : null, action: showMore ? TextButton( onPressed: () => setState(() => _showAllOnThisDay = !_showAllOnThisDay), child: Text(_showAllOnThisDay ? 'Show less' : 'Show more'), ) : null, child: filtered.isEmpty ? Text( 'No historical moves for today yet.', style: textTheme.bodyMedium, ) : Column( children: [ for (int idx = 0; idx < visible.length; idx++) ...[ _otdRow(context, visible[idx], textTheme, distanceUnits), if (idx != visible.length - 1) const Divider(height: 12), ], ], ), ); } Widget _otdRow(BuildContext context, Leg leg, TextTheme textTheme, DistanceUnitService distanceUnits) { final traction = leg.locos; return Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Container( width: 64, height: 56, padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(12), ), child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( leg.beginTime.year.toString(), style: textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w800, ), ), const SizedBox(height: 2), Text(_formatTime(leg.beginTime), style: textTheme.labelSmall), ], ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '${leg.start} → ${leg.end}', style: textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w700, ), maxLines: 2, overflow: TextOverflow.ellipsis, ), if (leg.headcode.isNotEmpty) ...[ const SizedBox(height: 4), Row( children: [ Text( leg.headcode, style: textTheme.labelSmall?.copyWith( color: textTheme.bodySmall?.color?.withValues( alpha: 0.7, ), ), ), ], ), ], const SizedBox(height: 4), Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(width: 6), Expanded( child: traction.isEmpty ? Text( 'No traction recorded', style: textTheme.labelSmall?.copyWith( color: textTheme.bodySmall?.color?.withValues( alpha: 0.7, ), ), ) : Wrap( spacing: 8, runSpacing: 4, children: traction.map((loco) { final iconColor = loco.powering ? Theme.of(context).colorScheme.primary : Theme.of(context).hintColor; final label = '${loco.locoClass} ${loco.number}'; return Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.train, size: 14, color: iconColor), const SizedBox(width: 4), Text( label, style: textTheme.labelSmall?.copyWith( color: textTheme.bodySmall?.color ?.withValues(alpha: 0.85), ), ), ], ); }).toList(), ), ), ], ), ], ), ), const SizedBox(width: 12), Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( distanceUnits.format(leg.mileage, decimals: 1), style: textTheme.labelLarge?.copyWith( fontWeight: FontWeight.w800, ), ), ], ), ], ); } Widget _panel( BuildContext context, { required IconData icon, required String title, required Widget child, Widget? trailing, Widget? action, }) { return Card( clipBehavior: Clip.antiAlias, child: Padding( padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(icon, size: 20), const SizedBox(width: 8), Expanded( child: Text( title, style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w800, ), ), ), if (action != null) Padding( padding: const EdgeInsets.only(right: 8.0), child: action, ), if (trailing != null) trailing, ], ), const SizedBox(height: 12), child, ], ), ), ); } Widget _buildTripsCard( BuildContext context, DataService data, DistanceUnitService distanceUnits) { final tripsUnsorted = data.trips; List trips = []; if (tripsUnsorted.isNotEmpty) { trips = [...tripsUnsorted]..sort(TripSummary.compareByDateDesc); } return _panel( context, icon: Icons.bookmark, title: 'Trips', action: TextButton( onPressed: () => context.push('/logbook/trips'), child: const Text('View all'), ), child: trips.isEmpty ? Text( 'No trips logged yet. Add one from the Trips page.', style: Theme.of(context).textTheme.bodyMedium, ) : Column( children: trips.take(6).map((trip) { return Padding( padding: const EdgeInsets.symmetric(vertical: 6.0), child: Row( children: [ Container( width: 42, height: 42, decoration: BoxDecoration( color: Theme.of(context).colorScheme.primaryContainer, borderRadius: BorderRadius.circular(12), ), child: const Icon(Icons.book, size: 18), ), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( trip.tripName, style: Theme.of(context).textTheme.titleSmall ?.copyWith(fontWeight: FontWeight.w700), ), AnimatedCountText( value: trip.tripMileage, formatter: (val) => distanceUnits.format(val, decimals: 1), style: Theme.of(context).textTheme.labelMedium, ), ], ), ), ], ), ); }).toList(), ), ); } String _formatTime(DateTime date) { return DateFormat('HH:mm').format(date); } Future _refreshDashboardData({bool force = false}) async { final data = context.read(); await data.fetchHomepageStats(); await Future.wait([ data.fetchOnThisDay(), data.fetchTripDetails(), data.fetchHadTraction(), data.fetchLatestLocoChanges(), data.fetchClassClearanceProgress(limit: 75, onlyIncomplete: true), ]); } void _handleRouteFocus() { final isCurrent = ModalRoute.of(context)?.isCurrent ?? true; if (isCurrent && !_isCurrent) { _isCurrent = true; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; _refreshDashboardData(); }); return; } if (!isCurrent && _isCurrent) { _isCurrent = false; } } }