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:
@@ -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)} · '
|
||||
|
||||
Reference in New Issue
Block a user