add support for badges and notifications, adjust nav pages
All checks were successful
All checks were successful
This commit is contained in:
@@ -12,6 +12,7 @@ class TripsPage extends StatefulWidget {
|
||||
|
||||
class _TripsPageState extends State<TripsPage> {
|
||||
bool _initialised = false;
|
||||
final Map<int, Future<List<TripLocoStat>>> _tripLocoStatsFutures = {};
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
@@ -23,7 +24,13 @@ class _TripsPageState extends State<TripsPage> {
|
||||
}
|
||||
|
||||
Future<void> _refreshTrips() async {
|
||||
await context.read<DataService>().fetchTripDetails();
|
||||
_tripLocoStatsFutures.clear();
|
||||
final data = context.read<DataService>();
|
||||
await data.fetchTripDetails();
|
||||
if (!mounted) return;
|
||||
for (final trip in data.tripDetails) {
|
||||
_tripStatsFuture(trip.id);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _renameTrip(TripDetail trip, String newName) async {
|
||||
@@ -47,6 +54,13 @@ class _TripsPageState extends State<TripsPage> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<TripLocoStat>> _tripStatsFuture(int tripId) {
|
||||
return _tripLocoStatsFutures.putIfAbsent(
|
||||
tripId,
|
||||
() => context.read<DataService>().fetchTripLocoStats(tripId),
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> _promptTripName(BuildContext context, String initial) async {
|
||||
final controller = TextEditingController(text: initial);
|
||||
final newName = await showDialog<String>(
|
||||
@@ -80,7 +94,6 @@ class _TripsPageState extends State<TripsPage> {
|
||||
final data = context.watch<DataService>();
|
||||
final tripDetails = data.tripDetails;
|
||||
final tripSummaries = data.trips;
|
||||
final isMobile = MediaQuery.of(context).size.width < 700;
|
||||
final showLoading = data.isTripDetailsLoading && tripDetails.isEmpty;
|
||||
|
||||
return RefreshIndicator(
|
||||
@@ -171,92 +184,191 @@ class _TripsPageState extends State<TripsPage> {
|
||||
}
|
||||
|
||||
final trip = tripDetails[index - 1];
|
||||
return _buildTripCard(context, trip, isMobile);
|
||||
return _buildTripCard(context, trip);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTripCard(BuildContext context, TripDetail trip, bool isMobile) {
|
||||
Widget _buildTripCard(BuildContext context, TripDetail trip) {
|
||||
final legs = trip.legs;
|
||||
final legCount = trip.legCount > 0 ? trip.legCount : legs.length;
|
||||
final dateRange = _formatDateRange(legs);
|
||||
final endpoints = _formatEndpoints(legs);
|
||||
final statsFuture = _tripStatsFuture(trip.id);
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Trip',
|
||||
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.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
trip.name,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
trip.mileage.toStringAsFixed(1),
|
||||
style:
|
||||
Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
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),
|
||||
'miles',
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: Theme.of(context).textTheme.bodySmall?.color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
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,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FutureBuilder<List<TripLocoStat>>(
|
||||
future: statsFuture,
|
||||
builder: (context, snapshot) {
|
||||
final chips = <Widget>[
|
||||
_buildMetaChip(context, Icons.timeline, '$legCount legs'),
|
||||
if (dateRange != null)
|
||||
_buildMetaChip(context, Icons.calendar_month, dateRange),
|
||||
if (endpoints != null)
|
||||
_buildMetaChip(context, Icons.route, endpoints),
|
||||
];
|
||||
|
||||
final stats = snapshot.data ?? const [];
|
||||
final hasStats = stats.isNotEmpty;
|
||||
final loading =
|
||||
snapshot.connectionState == ConnectionState.waiting;
|
||||
|
||||
if (loading && !hasStats) {
|
||||
chips.add(
|
||||
_buildMetaChip(context, Icons.train, 'Loading traction...'),
|
||||
);
|
||||
} else if (hasStats) {
|
||||
final winnerCount = stats.where((e) => e.won).length;
|
||||
chips.add(
|
||||
_buildMetaChip(context, Icons.train, '${stats.length} had'),
|
||||
);
|
||||
chips.add(
|
||||
_buildMetaChip(
|
||||
context,
|
||||
Icons.emoji_events_outlined,
|
||||
'$winnerCount winners',
|
||||
),
|
||||
);
|
||||
}).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,
|
||||
),
|
||||
} else if (snapshot.connectionState == ConnectionState.done) {
|
||||
chips.add(
|
||||
_buildMetaChip(context, Icons.train, 'No traction yet'),
|
||||
);
|
||||
}
|
||||
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: chips,
|
||||
);
|
||||
},
|
||||
),
|
||||
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),
|
||||
),
|
||||
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')}';
|
||||
@@ -323,6 +435,7 @@ class _TripsPageState extends State<TripsPage> {
|
||||
data.fetchTripDetails(),
|
||||
data.fetchTrips(),
|
||||
]);
|
||||
_tripLocoStatsFutures.remove(trip.id);
|
||||
if (!mounted) return;
|
||||
messenger?.showSnackBar(
|
||||
SnackBar(content: Text('Deleted "${trip.name}"')),
|
||||
@@ -424,10 +537,9 @@ class _TripsPageState extends State<TripsPage> {
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (_) {
|
||||
final data = context.read<DataService>();
|
||||
return SafeArea(
|
||||
child: FutureBuilder<List<TripLocoStat>>(
|
||||
future: data.fetchTripLocoStats(trip.id),
|
||||
future: _tripStatsFuture(trip.id),
|
||||
builder: (ctx, snapshot) {
|
||||
final items = snapshot.data ?? [];
|
||||
final loading =
|
||||
|
||||
Reference in New Issue
Block a user