import 'package:flutter/material.dart'; import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/services/data_service.dart'; import 'package:mileograph_flutter/services/distance_unit_service.dart'; import 'package:provider/provider.dart'; class TripsPage extends StatefulWidget { const TripsPage({super.key}); @override State createState() => _TripsPageState(); } class _TripsPageState extends State { bool _initialised = false; final Map>> _tripLocoStatsFutures = {}; @override void didChangeDependencies() { super.didChangeDependencies(); if (!_initialised) { _initialised = true; _refreshTrips(); } } Future _refreshTrips() async { _tripLocoStatsFutures.clear(); final data = context.read(); await data.fetchTripDetails(); } Future _renameTrip(TripDetail trip, String newName) async { final data = context.read(); final api = data.api; final messenger = ScaffoldMessenger.maybeOf(context); try { await api.post('/trips/rename', { "trip_id": trip.id, "trip_name": newName, }); await data.fetchTripDetails(); } catch (e) { messenger?.showSnackBar( SnackBar(content: Text('Failed to rename trip: $e')), ); rethrow; } } List _cachedTripStats( TripDetail trip, TripSummary? summary, ) { if (trip.locoStats.isNotEmpty) return trip.locoStats; if (summary?.locoStats.isNotEmpty == true) return summary!.locoStats; return const []; } Future> _loadTripStats( TripDetail trip, TripSummary? summary, ) { final cached = _cachedTripStats(trip, summary); if (cached.isNotEmpty) return Future.value(cached); return _tripLocoStatsFutures.putIfAbsent( trip.id, () => context.read().fetchTripLocoStats(trip.id), ); } Future _promptTripName(BuildContext context, String initial) async { final controller = TextEditingController(text: initial); final newName = await showDialog( context: context, builder: (dialogCtx) => AlertDialog( title: const Text('Rename trip'), content: TextField( controller: controller, decoration: const InputDecoration(labelText: 'Trip name'), autofocus: true, ), actions: [ TextButton( onPressed: () => Navigator.of(dialogCtx).pop(), child: const Text('Cancel'), ), TextButton( onPressed: () => Navigator.of(dialogCtx).pop(controller.text.trim()), child: const Text('Save'), ), ], ), ); controller.dispose(); return newName; } @override Widget build(BuildContext context) { final data = context.watch(); final distanceUnits = context.watch(); final tripDetails = data.tripDetails; final tripSummaries = data.tripList; final summaryById = { for (final summary in tripSummaries) summary.tripId: summary, }; final showLoading = data.isTripDetailsLoading && tripDetails.isEmpty; return RefreshIndicator( onRefresh: _refreshTrips, child: ListView.builder( padding: const EdgeInsets.all(16), physics: const AlwaysScrollableScrollPhysics(), itemCount: () { if (showLoading) return 2; if (tripDetails.isEmpty && tripSummaries.isEmpty) return 2; if (tripDetails.isEmpty) return 1 + tripSummaries.length; return 1 + tripDetails.length; }(), itemBuilder: (context, index) { if (index == 0) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Journeys', style: Theme.of(context).textTheme.labelMedium, ), const SizedBox(height: 2), Text( 'Trips', style: Theme.of(context).textTheme.headlineSmall, ), ], ), IconButton( onPressed: _refreshTrips, icon: const Icon(Icons.refresh), tooltip: 'Refresh trips', ), ], ), const SizedBox(height: 12), ], ); } if (showLoading) { return const Center( child: Padding( padding: EdgeInsets.symmetric(vertical: 24.0), child: CircularProgressIndicator(), ), ); } if (tripDetails.isEmpty && tripSummaries.isEmpty) { return Card( child: Padding( padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'No trips yet', style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w700, ), ), const SizedBox(height: 8), const Text( 'Use the Add entry flow to start grouping legs into trips.', ), ], ), ), ); } if (tripDetails.isEmpty) { final trip = tripSummaries[index - 1]; return Card( child: ListTile( title: Text(trip.tripName), subtitle: Text(distanceUnits.format(trip.tripMileage, decimals: 1)), ), ); } final trip = tripDetails[index - 1]; final summary = summaryById[trip.id]; return _buildTripCard(context, trip, summary); }, ), ); } Widget _buildTripCard( BuildContext context, TripDetail trip, TripSummary? summary, ) { final distanceUnits = context.watch(); final legs = trip.legs; final legCount = trip.legCount > 0 ? trip.legCount : summary?.legCount ?? legs.length; final dateRange = _formatDateRange(legs); final endpoints = _formatEndpoints(legs); final stats = _cachedTripStats(trip, summary); final winnerCount = stats.where((e) => e.won).length; return Card( child: Padding( padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Trip #${trip.id}', style: Theme.of(context).textTheme.labelMedium, ), const SizedBox(height: 4), Text( trip.name, style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w700, ), ), ], ), ), Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( distanceUnits.format(trip.mileage, decimals: 1), style: Theme.of(context).textTheme.headlineSmall?.copyWith( fontWeight: FontWeight.w800, ), ), ], ), ], ), const SizedBox(height: 12), Wrap( spacing: 8, runSpacing: 8, children: [ _buildMetaChip(context, Icons.timeline, '$legCount legs'), if (dateRange != null) _buildMetaChip(context, Icons.calendar_month, dateRange), if (endpoints != null) _buildMetaChip(context, Icons.route, endpoints), if (stats.isNotEmpty) ...[ _buildMetaChip(context, Icons.train, '${stats.length} had'), _buildMetaChip( context, Icons.emoji_events_outlined, '$winnerCount winners', ), ] else _buildMetaChip(context, Icons.train, 'No traction yet'), ], ), const SizedBox(height: 12), Align( alignment: Alignment.centerRight, child: Wrap( spacing: 8, runSpacing: 8, alignment: WrapAlignment.end, children: [ OutlinedButton.icon( icon: const Icon(Icons.train), label: const Text('Locos'), onPressed: () => _showTripWinners(context, trip, summary), ), FilledButton.icon( icon: const Icon(Icons.open_in_new), label: const Text('Details'), onPressed: () => _showTripDetail(context, trip), ), ], ), ), ], ), ), ); } Widget _buildMetaChip(BuildContext context, IconData icon, String label) { return Chip( avatar: Icon(icon, size: 16), label: Text(label), visualDensity: const VisualDensity(horizontal: -2, vertical: -2), materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, ); } String? _formatDateRange(List legs) { final beginTimes = legs.map((e) => e.beginTime).whereType().toList(); if (beginTimes.isEmpty) return null; final start = beginTimes.first; final end = beginTimes.last; final startStr = _formatFriendlyDate(start); final endStr = _formatFriendlyDate(end); if (startStr == endStr) return startStr; return '$startStr - $endStr'; } String _formatFriendlyDate(DateTime date) { const months = [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', ]; final day = date.day.toString().padLeft(2, '0'); final monthIndex = (date.month - 1).clamp(0, months.length - 1).toInt(); final month = months[monthIndex]; return '$day $month ${date.year}'; } String? _formatEndpoints(List legs) { if (legs.isEmpty) return null; final start = legs.first.start; final end = legs.last.end; if (start.isEmpty && end.isEmpty) return null; final startLabel = start.isNotEmpty ? start : '—'; final endLabel = end.isNotEmpty ? end : '—'; return '$startLabel → $endLabel'; } String _formatDate(DateTime? date) { if (date == null) return ''; return '${date.year.toString().padLeft(4, '0')}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; } void _showTripDetail(BuildContext context, TripDetail trip) { final distanceUnits = context.read(); showModalBottomSheet( context: context, isScrollControlled: true, builder: (_) { bool renaming = false; bool deleting = false; String tripName = trip.name; return StatefulBuilder( builder: (sheetCtx, setSheetState) { Future handleRename() async { final newName = await _promptTripName(sheetCtx, tripName) ?? tripName; if (newName.isEmpty || newName == tripName) return; setSheetState(() => renaming = true); try { await _renameTrip(trip, newName); tripName = newName; setSheetState(() {}); } finally { if (mounted) setSheetState(() => renaming = false); } } Future handleDelete() async { if (deleting || trip.legs.isNotEmpty) return; final data = context.read(); final api = data.api; final messenger = ScaffoldMessenger.maybeOf(sheetCtx); final navigator = Navigator.of(sheetCtx); final ok = await showDialog( context: sheetCtx, builder: (ctx) { return AlertDialog( title: const Text('Delete trip?'), content: Text( 'This will delete "${trip.name}". This cannot be undone.', ), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Cancel'), ), FilledButton( onPressed: () => Navigator.of(ctx).pop(true), child: const Text('Delete'), ), ], ); }, ); if (ok != true || !mounted) return; setSheetState(() => deleting = true); try { await api.delete('/trips/delete/${trip.id}'); await Future.wait([ data.fetchTripDetails(), data.fetchTrips(), ]); _tripLocoStatsFutures.remove(trip.id); if (!mounted) return; messenger?.showSnackBar( SnackBar(content: Text('Deleted "${trip.name}"')), ); navigator.pop(); } catch (e) { if (!mounted) return; messenger?.showSnackBar( SnackBar(content: Text('Failed to delete trip: $e')), ); } finally { if (mounted) setSheetState(() => deleting = false); } } return SafeArea( child: Padding( padding: const EdgeInsets.all(16.0), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ IconButton( icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.of(sheetCtx).pop(), ), Expanded( child: Text( tripName, style: Theme.of(context).textTheme.titleMedium ?.copyWith(fontWeight: FontWeight.bold), ), ), IconButton( icon: renaming ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.edit), tooltip: 'Rename trip', onPressed: renaming ? null : handleRename, ), if (trip.legs.isEmpty) ...[ const SizedBox(width: 4), IconButton( icon: deleting ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.delete_outline), tooltip: 'Delete trip', onPressed: deleting ? null : handleDelete, color: Theme.of(context).colorScheme.error, ), ], const SizedBox(width: 4), Text( distanceUnits.format(trip.mileage, decimals: 1), ), ], ), const SizedBox(height: 8), SizedBox( height: MediaQuery.of(context).size.height * 0.6, child: ListView.builder( itemCount: trip.legs.length, itemBuilder: (context, index) { final leg = trip.legs[index]; return ListTile( leading: const Icon(Icons.train), title: Text('${leg.start} → ${leg.end}'), subtitle: Text(_formatDate(leg.beginTime)), trailing: Text( leg.mileage == null ? '-' : distanceUnits.format( leg.mileage!, decimals: 1, ), style: Theme.of(context).textTheme.labelLarge ?.copyWith(fontWeight: FontWeight.bold), ), ); }, ), ), ], ), ), ); }, ); }, ); } void _showTripWinners( BuildContext context, TripDetail trip, TripSummary? summary, ) { final distanceUnits = context.read(); showModalBottomSheet( context: context, isScrollControlled: true, builder: (_) { return SafeArea( child: FutureBuilder>( future: _loadTripStats(trip, summary), initialData: _cachedTripStats(trip, summary), builder: (ctx, snapshot) { final items = snapshot.data ?? []; final loading = snapshot.connectionState == ConnectionState.waiting; return Padding( padding: const EdgeInsets.all(16.0), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ IconButton( icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.of(context).pop(), ), Text( trip.name, style: Theme.of(context) .textTheme .titleMedium ?.copyWith(fontWeight: FontWeight.bold), ), const Spacer(), Text( distanceUnits.format(trip.mileage, decimals: 1), ), ], ), const SizedBox(height: 8), if (!loading && items.isNotEmpty) ...[ Wrap( spacing: 8, runSpacing: 8, children: [ Chip( avatar: const Icon(Icons.train, size: 16), label: Text('Total had: ${items.length}'), ), Chip( avatar: const Icon(Icons.star, size: 16), label: Text( 'Winners: ${items.where((e) => e.won == true).length}', ), ), ], ), const SizedBox(height: 8), ], if (loading) const Center( child: Padding( padding: EdgeInsets.symmetric(vertical: 24.0), child: CircularProgressIndicator(), ), ) else if (items.isEmpty) const Padding( padding: EdgeInsets.symmetric(vertical: 16.0), child: Text('No traction recorded for this trip yet.'), ) else SizedBox( height: MediaQuery.of(context).size.height * 0.6, child: ListView.builder( itemCount: items.length, itemBuilder: (context, index) { final loco = items[index]; final won = loco.won; final isWon = won == true; return ListTile( leading: const Icon(Icons.train), title: Text('${loco.locoClass} ${loco.number}'), subtitle: loco.name == null || loco.name!.isEmpty ? null : Text(loco.name!), trailing: Chip( label: Text(isWon ? 'Won' : 'Dud'), backgroundColor: isWon ? Colors.green.shade100 : Colors.grey.shade300, labelStyle: TextStyle( color: isWon ? Colors.green.shade900 : Colors.grey.shade800, ), ), ); }, ), ), ], ), ); }, ), ); }, ); } }