T19/T21: map and export, completing Phase 4

flutter_map with the same OSM tiles and no-API-key reasoning that chose osmdroid.
All four load-bearing behaviours ported and, unlike the native app's map, tested:
one polyline per segment so a pause is a real gap, render-only decimation, the
zoom clamp at OSM's max tile zoom with a short-ride fallback, and a real user
agent. Speed colouring is bucketed per run rather than per-vertex, since neither
osmdroid nor flutter_map makes per-vertex paint reasonable.

Export via share_plus, which also handles the iPad popover anchor a naive port
forgets. It passes the raw stored points, never the map's decimated path.

164 tests passing, analyze clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 16:27:42 -05:00
parent bcc12b314f
commit d501528e69
20 changed files with 1085 additions and 478 deletions

View File

@@ -15,8 +15,9 @@ import '../format.dart';
/// Selection is screen-local: it must not survive navigating away, and nothing else
/// needs to observe it.
final _selectionProvider =
StateProvider.autoDispose<Set<int>>((ref) => <int>{});
final _selectionProvider = StateProvider.autoDispose<Set<int>>(
(ref) => <int>{},
);
class TripsScreen extends ConsumerWidget {
const TripsScreen({super.key, this.onOpenTrip, this.onBack});
@@ -31,9 +32,9 @@ class TripsScreen extends ConsumerWidget {
final trips = tripsAsync.valueOrNull ?? const <Trip>[];
// Drop ids that no longer exist, so a deleted trip cannot linger in the selection.
final live = ref.watch(_selectionProvider).intersection(
trips.map((t) => t.id).toSet(),
);
final live = ref
.watch(_selectionProvider)
.intersection(trips.map((t) => t.id).toSet());
final selecting = live.isNotEmpty;
// Merge combines exactly two rides; anything else is ambiguous.
final canMerge = live.length == 2;
@@ -61,8 +62,7 @@ class TripsScreen extends ConsumerWidget {
padding: const EdgeInsets.only(left: 8),
child: Text(
'${live.length} selected',
style:
TextStyle(fontSize: 14, color: colors.outline),
style: TextStyle(fontSize: 14, color: colors.outline),
),
),
] else ...[
@@ -96,8 +96,10 @@ class TripsScreen extends ConsumerWidget {
TextButton(
key: const Key('delete'),
onPressed: () => _confirmDelete(context, ref, live),
child: Text('Delete',
style: TextStyle(color: colors.error)),
child: Text(
'Delete',
style: TextStyle(color: colors.error),
),
),
],
],
@@ -108,29 +110,28 @@ class TripsScreen extends ConsumerWidget {
AsyncValue(hasValue: true, value: final list)
when (list ?? const []).isEmpty =>
const EmptyState(
message:
'No rides yet.\nPress Start on the record screen to capture one.',
message: 'No rides yet.\nPress Start on the record screen to capture one.',
icon: Icons.two_wheeler,
),
AsyncValue(hasValue: true) => ListView.builder(
itemCount: trips.length,
itemBuilder: (context, i) {
final trip = trips[i];
return _TripTile(
trip: trip,
selected: live.contains(trip.id),
selecting: selecting,
onTap: () {
if (selecting) {
_toggle(ref, trip.id);
} else {
onOpenTrip?.call(trip.id);
}
},
onLongPress: () => _toggle(ref, trip.id),
);
},
),
itemCount: trips.length,
itemBuilder: (context, i) {
final trip = trips[i];
return _TripTile(
trip: trip,
selected: live.contains(trip.id),
selecting: selecting,
onTap: () {
if (selecting) {
_toggle(ref, trip.id);
} else {
onOpenTrip?.call(trip.id);
}
},
onLongPress: () => _toggle(ref, trip.id),
);
},
),
_ => const Center(child: CircularProgressIndicator()),
},
),
@@ -150,7 +151,10 @@ class TripsScreen extends ConsumerWidget {
}
Future<void> _confirmDelete(
BuildContext context, WidgetRef ref, Set<int> selection) async {
BuildContext context,
WidgetRef ref,
Set<int> selection,
) async {
final ok = await confirmDialog(
context,
title: 'Delete ${selection.length} ride(s)?',
@@ -166,15 +170,20 @@ class TripsScreen extends ConsumerWidget {
ref.read(_selectionProvider.notifier).state = <int>{};
}
Future<void> _confirmMerge(BuildContext context, WidgetRef ref,
List<Trip> trips, Set<int> selection) async {
Future<void> _confirmMerge(
BuildContext context,
WidgetRef ref,
List<Trip> trips,
Set<int> selection,
) async {
final total = trips
.where((t) => selection.contains(t.id))
.fold<double>(0, (a, t) => a + t.distanceM);
final ok = await confirmDialog(
context,
title: 'Merge two rides?',
message: 'They become one ride of about ${formatDistance(total)}. '
message:
'They become one ride of about ${formatDistance(total)}. '
'The join is kept as a segment break, so no straight line is drawn '
'across it.',
confirmLabel: 'Merge',
@@ -218,8 +227,10 @@ class _TripTile extends StatelessWidget {
color: selected ? colors.primary : colors.outline,
)
: null,
title: Text(tripLabel(trip),
style: const TextStyle(fontWeight: FontWeight.w600)),
title: Text(
tripLabel(trip),
style: const TextStyle(fontWeight: FontWeight.w600),
),
subtitle: Text(
'${formatDistance(trip.distanceM)} · '
'${formatDuration(trip.movingMillis)} · '