add support for badges and notifications, adjust nav pages
All checks were successful
Release / meta (push) Successful in 7s
Release / linux-build (push) Successful in 6m49s
Release / android-build (push) Successful in 15m55s
Release / release-master (push) Successful in 24s
Release / release-dev (push) Successful in 26s

This commit is contained in:
2025-12-26 18:36:37 +00:00
parent 44d79e7c28
commit 4bd6f0bbed
16 changed files with 1161 additions and 144 deletions

View File

@@ -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 =