import 'package:flutter/material.dart'; import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/services/data_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; @override void didChangeDependencies() { super.didChangeDependencies(); if (!_initialised) { _initialised = true; _refreshTrips(); } } Future _refreshTrips() async { await context.read().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 Future.wait([ data.fetchTripDetails(), data.fetchTrips(), ]); } catch (e) { messenger?.showSnackBar( SnackBar(content: Text('Failed to rename trip: $e')), ); rethrow; } } 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 tripDetails = data.tripDetails; final tripSummaries = data.trips; final isMobile = MediaQuery.of(context).size.width < 700; 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('${trip.tripMileage.toStringAsFixed(1)} mi'), ), ); } final trip = tripDetails[index - 1]; return _buildTripCard(context, trip, isMobile); }, ), ); } Widget _buildTripCard(BuildContext context, TripDetail trip, bool isMobile) { final legs = trip.legs; return Card( child: Padding( padding: const EdgeInsets.all(12.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( trip.name, style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w700, ), ), Text( '${trip.mileage.toStringAsFixed(1)} mi · ${trip.legCount} legs', style: Theme.of(context).textTheme.bodyMedium, ), ], ), Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ IconButton( icon: const Icon(Icons.train), tooltip: 'Traction', onPressed: () => _showTripWinners(context, trip), ), IconButton( icon: const Icon(Icons.open_in_new), tooltip: 'Details', onPressed: () => _showTripDetail(context, trip), ), ], ), ], ), const SizedBox(height: 8), if (legs.isNotEmpty) Column( children: legs.take(isMobile ? 2 : 3).map((leg) { return ListTile( dense: isMobile, contentPadding: EdgeInsets.zero, leading: const Icon(Icons.train), title: Text('${leg.start} → ${leg.end}'), subtitle: Text( _formatDate(leg.beginTime), maxLines: 1, overflow: TextOverflow.ellipsis, ), trailing: Text( leg.mileage?.toStringAsFixed(1) ?? '-', style: Theme.of(context).textTheme.labelLarge?.copyWith( fontWeight: FontWeight.bold, ), ), ); }).toList(), ), if (legs.length > 3) Padding( padding: const EdgeInsets.only(top: 6.0), child: Text( '+${legs.length - 3} more legs', style: Theme.of(context).textTheme.bodySmall, ), ), ], ), ), ); } 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) { 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(), ]); 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('${trip.mileage.toStringAsFixed(1)} mi'), ], ), 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?.toStringAsFixed(1) ?? '-', style: Theme.of(context).textTheme.labelLarge ?.copyWith(fontWeight: FontWeight.bold), ), ); }, ), ), ], ), ), ); }, ); }, ); } void _showTripWinners(BuildContext context, TripDetail trip) { showModalBottomSheet( context: context, isScrollControlled: true, builder: (_) { final data = context.read(); return SafeArea( child: FutureBuilder>( future: data.fetchTripLocoStats(trip.id), 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('${trip.mileage.toStringAsFixed(1)} mi'), ], ), 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, ), ), ); }, ), ), ], ), ); }, ), ); }, ); } }