Files
mileograph_flutter/lib/components/pages/trips.dart
Pete Gregory 29cecf0ded
All checks were successful
Release / meta (push) Successful in 10s
Release / linux-build (push) Successful in 6m32s
Release / web-build (push) Successful in 5m50s
Release / android-build (push) Successful in 20m43s
Release / release-master (push) Successful in 26s
Release / release-dev (push) Successful in 28s
add android bundle release
2026-01-02 14:34:11 +00:00

649 lines
23 KiB
Dart

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<TripsPage> createState() => _TripsPageState();
}
class _TripsPageState extends State<TripsPage> {
bool _initialised = false;
final Map<int, Future<List<TripLocoStat>>> _tripLocoStatsFutures = {};
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_initialised) {
_initialised = true;
_refreshTrips();
}
}
Future<void> _refreshTrips() async {
_tripLocoStatsFutures.clear();
final data = context.read<DataService>();
await data.fetchTripDetails();
}
Future<void> _renameTrip(TripDetail trip, String newName) async {
final data = context.read<DataService>();
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<TripLocoStat> _cachedTripStats(
TripDetail trip,
TripSummary? summary,
) {
if (trip.locoStats.isNotEmpty) return trip.locoStats;
if (summary?.locoStats.isNotEmpty == true) return summary!.locoStats;
return const [];
}
Future<List<TripLocoStat>> _loadTripStats(
TripDetail trip,
TripSummary? summary,
) {
final cached = _cachedTripStats(trip, summary);
if (cached.isNotEmpty) return Future.value(cached);
return _tripLocoStatsFutures.putIfAbsent(
trip.id,
() => context.read<DataService>().fetchTripLocoStats(trip.id),
);
}
Future<String?> _promptTripName(BuildContext context, String initial) async {
final controller = TextEditingController(text: initial);
final newName = await showDialog<String>(
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<DataService>();
final distanceUnits = context.watch<DistanceUnitService>();
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<DistanceUnitService>();
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<TripLeg> legs) {
final beginTimes =
legs.map((e) => e.beginTime).whereType<DateTime>().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<TripLeg> 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<DistanceUnitService>();
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (_) {
bool renaming = false;
bool deleting = false;
String tripName = trip.name;
return StatefulBuilder(
builder: (sheetCtx, setSheetState) {
Future<void> 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<void> handleDelete() async {
if (deleting || trip.legs.isNotEmpty) return;
final data = context.read<DataService>();
final api = data.api;
final messenger = ScaffoldMessenger.maybeOf(sheetCtx);
final navigator = Navigator.of(sheetCtx);
final ok = await showDialog<bool>(
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<DistanceUnitService>();
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (_) {
return SafeArea(
child: FutureBuilder<List<TripLocoStat>>(
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,
),
),
);
},
),
),
],
),
);
},
),
);
},
);
}
}