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

@@ -0,0 +1,213 @@
/// Ported from `com.rippr.ui.components.RideMap` (osmdroid → flutter_map).
///
/// Same tile source, same reasoning that chose osmdroid: OpenStreetMap raster tiles, no
/// API key, no billing.
///
/// ## Four things here are load-bearing
///
/// 1. **One polyline per segment**, so a pause leaves a visible gap instead of a
/// straight line drawn across town.
/// 2. **Decimation is render-only.** `simplify` never touches storage or export.
/// 3. **The zoom clamp.** Fitting a 50 m ride zooms past OpenStreetMap's maximum tile
/// zoom of 19 and renders an empty grid — that shipped in v2.0 and is invisible to
/// any fixture of the wrong size.
/// 4. **A real user agent**, or OSM's tile servers return 403.
library;
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart' as ll;
import '../../domain/models.dart';
import '../../geo/geo.dart' as geo;
/// Metres. Render-only: a 3-hour ride is ~21,600 points and would jank undecimated.
const double simplifyEpsilonM = 5.0;
/// Speed colouring is bucketed rather than continuous — osmdroid's per-vertex paint was
/// fiddly, and flutter_map has no equivalent either. Buckets also read better at a glance.
const int speedBucketKmh = 10;
/// OpenStreetMap serves no raster tiles above this. Exceeding it renders a blank grid.
const double maxTileZoom = 19.0;
/// What a very short ride falls back to, so streets stay visible.
const double shortRideZoom = 17.0;
/// Identifies the app to OSM's servers. Anonymous bulk requests get 403.
const String tileUserAgent = 'com.rippr.port';
class RideMap extends StatefulWidget {
const RideMap({
super.key,
required this.points,
required this.segments,
this.height = 320,
});
final List<TrackPoint> points;
final List<Segment> segments;
final double height;
@override
State<RideMap> createState() => _RideMapState();
}
class _RideMapState extends State<RideMap> {
final _controller = MapController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
/// Points grouped by segment, in recording order.
List<List<TrackPoint>> get _bySegment {
final grouped = <int, List<TrackPoint>>{};
for (final p in widget.points) {
(grouped[p.segmentId] ??= <TrackPoint>[]).add(p);
}
return grouped.values.toList();
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
if (widget.points.isEmpty) {
return SizedBox(
height: widget.height,
child: Center(
child: Text(
'No path recorded',
style: TextStyle(color: colors.outline),
),
),
);
}
final all = [
for (final p in widget.points) geo.LatLon(p.latitude, p.longitude),
];
final bounds = geo.bounds(all)!;
final polylines = _buildPolylines(colors);
return SizedBox(
height: widget.height,
// The map draws to the edge of its box; clipping keeps it from painting over
// adjacent controls, which osmdroid did until it was explicitly bounded.
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: FlutterMap(
mapController: _controller,
options: MapOptions(
initialCameraFit: bounds.isDegenerate
// Every point at one spot — a parked "ride". Fitting this would zoom to
// infinity, so centre and use a sane street-level zoom instead.
? null
: CameraFit.bounds(
bounds: LatLngBounds(
ll.LatLng(bounds.minLat, bounds.minLon),
ll.LatLng(bounds.maxLat, bounds.maxLon),
),
padding: const EdgeInsets.all(24),
// The clamp. Without it a short ride lands past OSM's max tile zoom
// and renders an empty grid.
maxZoom: maxTileZoom,
),
initialCenter: ll.LatLng(bounds.centerLat, bounds.centerLon),
initialZoom: bounds.isDegenerate ? shortRideZoom : maxTileZoom,
maxZoom: maxTileZoom,
interactionOptions: const InteractionOptions(
flags: InteractiveFlag.pinchZoom | InteractiveFlag.drag,
),
),
children: [
TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: tileUserAgent,
maxNativeZoom: maxTileZoom.toInt(),
// Respect OSM's usage policy: render what is looked at, never bulk prefetch.
panBuffer: 0,
),
PolylineLayer(polylines: polylines),
],
),
),
);
}
/// One polyline per speed run within each segment.
///
/// Splitting on bucket change gives speed colouring without per-vertex paint. Segments
/// are never joined, so a pause is a genuine gap.
List<Polyline> _buildPolylines(ColorScheme colors) {
final maxSpeed = widget.points
.map((p) => p.speedKmh)
.fold<double>(0, (a, b) => a > b ? a : b);
final out = <Polyline>[];
for (final segment in _bySegment) {
if (segment.isEmpty) continue;
// Decimate for rendering only. The stored and exported points are untouched.
final simplified = geo.simplify([
for (final p in segment) geo.LatLon(p.latitude, p.longitude),
], simplifyEpsilonM);
if (simplified.length < 2) continue;
// Walk the simplified path, splitting whenever the speed bucket changes.
var runStart = 0;
var runBucket = _bucketOf(segment.first.speedKmh);
for (var i = 1; i <= simplified.length; i++) {
final bucket = i < simplified.length
? _bucketOf(_speedNear(segment, simplified[i]))
: -1;
if (bucket != runBucket || i == simplified.length) {
if (i - runStart >= 2) {
out.add(
Polyline(
points: [
for (final p in simplified.sublist(runStart, i))
ll.LatLng(p.lat, p.lon),
],
strokeWidth: 4,
color: _colorFor(runBucket, maxSpeed, colors),
),
);
}
runStart = i - 1;
runBucket = bucket;
}
}
}
return out;
}
/// The speed of the original point nearest a simplified vertex.
///
/// Simplification drops points, so colouring has to come from the raw data rather than
/// from the decimated path.
double _speedNear(List<TrackPoint> segment, geo.LatLon vertex) {
for (final p in segment) {
if (p.latitude == vertex.lat && p.longitude == vertex.lon) {
return p.speedKmh;
}
}
return 0;
}
int _bucketOf(double speedKmh) => (speedKmh / speedBucketKmh).floor();
/// Cool for slow, accent for fast. A single hue ramp reads better through a visor than
/// a rainbow.
Color _colorFor(int bucket, double maxSpeed, ColorScheme colors) {
if (bucket < 0 || maxSpeed <= 0) return colors.primary;
final t = ((bucket * speedBucketKmh) / maxSpeed).clamp(0.0, 1.0);
return Color.lerp(const Color(0xFF4FC3F7), colors.primary, t)!;
}
}

View File

@@ -8,9 +8,11 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/providers.dart';
import '../../export/export_manager.dart';
import '../../domain/models.dart';
import '../../stats/ride_statistics.dart';
import '../../telemetry/telemetry.dart';
import '../components/ride_map.dart';
import '../components/stats.dart';
import '../format.dart';
@@ -23,6 +25,7 @@ class TripDetail {
required this.segments,
required this.histogram,
required this.profile,
required this.mapEnabled,
});
final Trip trip;
@@ -31,10 +34,16 @@ class TripDetail {
final List<Segment> segments;
final List<SpeedBucket> histogram;
final List<ElevationSample> profile;
/// The map stays behind a toggle, as in v2: it is only ever live on a visible screen,
/// and never while recording in the background.
final bool mapEnabled;
}
final tripDetailProvider =
FutureProvider.autoDispose.family<TripDetail?, int>((ref, tripId) async {
final tripDetailProvider = FutureProvider.autoDispose.family<TripDetail?, int>((
ref,
tripId,
) async {
final repo = ref.watch(tripRepositoryProvider);
final trip = await repo.tripById(tripId);
if (trip == null) return null;
@@ -49,6 +58,7 @@ final tripDetailProvider =
segments: segments,
histogram: speedHistogram(points),
profile: elevationProfile(points),
mapEnabled: ref.watch(mapEnabledProvider),
);
});
@@ -69,33 +79,73 @@ class TripDetailScreen extends ConsumerWidget {
icon: const Icon(Icons.arrow_back),
onPressed: onBack,
),
title: Text(async.valueOrNull == null
? 'Ride'
: tripLabel(async.value!.trip)),
title: Text(
async.valueOrNull == null ? 'Ride' : tripLabel(async.value!.trip),
),
actions: [
if (async.valueOrNull != null)
IconButton(
key: const Key('rename'),
icon: const Icon(Icons.edit_outlined),
onPressed: () => _rename(context, ref, async.value!.trip),
key: const Key('export'),
icon: const Icon(Icons.ios_share),
onPressed: () => _export(context, async.value!),
),
IconButton(
key: const Key('rename'),
icon: const Icon(Icons.edit_outlined),
onPressed: () => _rename(context, ref, async.value!.trip),
),
],
),
body: switch (async) {
AsyncValue(hasValue: true, value: null) => const EmptyState(
message: 'This ride no longer exists.',
icon: Icons.help_outline,
),
message: 'This ride no longer exists.',
icon: Icons.help_outline,
),
AsyncValue(hasValue: true, value: final d?) => _Body(detail: d),
AsyncValue(hasError: true, :final error) =>
EmptyState(message: 'Could not load this ride.\n$error'),
AsyncValue(hasError: true, :final error) => EmptyState(
message: 'Could not load this ride.\n$error',
),
_ => const Center(child: CircularProgressIndicator()),
},
);
}
Future<void> _rename(
BuildContext context, WidgetRef ref, Trip trip) async {
Future<void> _export(BuildContext context, TripDetail d) async {
final format = await showModalBottomSheet<ExportFormat>(
context: context,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
key: const Key('export-gpx'),
leading: const Icon(Icons.route),
title: const Text('GPX'),
subtitle: const Text('Strava, Garmin, Google Earth'),
onTap: () => Navigator.of(context).pop(ExportFormat.gpx),
),
ListTile(
key: const Key('export-geojson'),
leading: const Icon(Icons.data_object),
title: const Text('GeoJSON'),
subtitle: const Text('Mapping and analysis tools'),
onTap: () => Navigator.of(context).pop(ExportFormat.geojson),
),
],
),
),
);
if (format == null) return;
await shareRide(
trip: d.trip,
segments: d.segments,
// Deliberately d.points, the raw stored points -- never the map's decimated path.
points: d.points,
format: format,
);
}
Future<void> _rename(BuildContext context, WidgetRef ref, Trip trip) async {
final controller = TextEditingController(text: trip.name ?? '');
final name = await showDialog<String>(
context: context,
@@ -146,29 +196,39 @@ class _Body extends StatelessWidget {
),
),
const SizedBox(height: 24),
if (detail.mapEnabled)
RideMap(points: detail.points, segments: detail.segments),
if (detail.mapEnabled) const SizedBox(height: 24),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
StatRow(
label: 'Moving time',
value: formatDuration(s.movingMillis)),
label: 'Moving time',
value: formatDuration(s.movingMillis),
),
StatRow(
label: 'Elapsed', value: formatDuration(s.elapsedMillis)),
label: 'Elapsed',
value: formatDuration(s.elapsedMillis),
),
StatRow(
label: 'Stopped', value: formatDuration(s.stoppedMillis)),
label: 'Stopped',
value: formatDuration(s.stoppedMillis),
),
StatRow(label: 'Max speed', value: formatSpeed(s.maxSpeedKmh)),
StatRow(
label: 'Max speed', value: formatSpeed(s.maxSpeedKmh)),
label: 'Avg moving speed',
value: formatSpeed(s.avgMovingSpeedKmh),
),
StatRow(
label: 'Avg moving speed',
value: formatSpeed(s.avgMovingSpeedKmh)),
label: 'Elevation gain',
value: formatElevation(s.elevationGainM),
),
StatRow(
label: 'Elevation gain',
value: formatElevation(s.elevationGainM)),
StatRow(
label: 'Elevation loss',
value: formatElevation(s.elevationLossM)),
label: 'Elevation loss',
value: formatElevation(s.elevationLossM),
),
StatRow(label: 'Points', value: '${s.pointCount}'),
StatRow(label: 'Segments', value: '${detail.segments.length}'),
],
@@ -193,16 +253,16 @@ class _SectionTitle extends StatelessWidget {
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
text.toUpperCase(),
style: TextStyle(
fontSize: 12,
letterSpacing: 2,
color: Theme.of(context).colorScheme.outline,
),
),
);
padding: const EdgeInsets.only(bottom: 8),
child: Text(
text.toUpperCase(),
style: TextStyle(
fontSize: 12,
letterSpacing: 2,
color: Theme.of(context).colorScheme.outline,
),
),
);
}
/// Time in each speed band.
@@ -220,8 +280,9 @@ class _Histogram extends StatelessWidget {
return const EmptyState(message: 'Not enough points to chart.');
}
final colors = Theme.of(context).colorScheme;
final maxMillis =
buckets.map((b) => b.millis).reduce((a, b) => a > b ? a : b);
final maxMillis = buckets
.map((b) => b.millis)
.reduce((a, b) => a > b ? a : b);
return Column(
children: [
@@ -232,8 +293,10 @@ class _Histogram extends StatelessWidget {
children: [
SizedBox(
width: 64,
child: Text(b.label,
style: TextStyle(fontSize: 12, color: colors.outline)),
child: Text(
b.label,
style: TextStyle(fontSize: 12, color: colors.outline),
),
),
Expanded(
child: LayoutBuilder(
@@ -331,8 +394,7 @@ class _ElevationPainter extends CustomPainter {
final path = Path();
for (var i = 0; i < samples.length; i++) {
final x = size.width * (samples[i].distanceM / xSpan);
final y =
size.height * (1 - (samples[i].altitudeM - minAlt) / span);
final y = size.height * (1 - (samples[i].altitudeM - minAlt) / span);
if (i == 0) {
path.moveTo(x, y);
} else {

View File

@@ -21,50 +21,48 @@ abstract final class Routes {
}
GoRouter buildRouter() => GoRouter(
initialLocation: Routes.record,
routes: [
GoRoute(
path: Routes.record,
builder: (context, state) => RecordScreen(
onOpenTrips: () => context.push(Routes.trips),
),
),
GoRoute(
path: Routes.trips,
builder: (context, state) => TripsScreen(
onOpenTrip: (id) => context.push(Routes.detailFor(id)),
onBack: () => context.pop(),
),
),
GoRoute(
path: Routes.tripDetail,
builder: (context, state) {
// Parsed explicitly. A malformed or missing id must land on a real screen
// saying so, never on a blank one or a crash.
final id = int.tryParse(state.pathParameters['tripId'] ?? '');
if (id == null) {
return const _NotFound();
}
return TripDetailScreen(tripId: id, onBack: () => context.pop());
},
),
],
errorBuilder: (context, state) => const _NotFound(),
);
initialLocation: Routes.record,
routes: [
GoRoute(
path: Routes.record,
builder: (context, state) =>
RecordScreen(onOpenTrips: () => context.push(Routes.trips)),
),
GoRoute(
path: Routes.trips,
builder: (context, state) => TripsScreen(
onOpenTrip: (id) => context.push(Routes.detailFor(id)),
onBack: () => context.pop(),
),
),
GoRoute(
path: Routes.tripDetail,
builder: (context, state) {
// Parsed explicitly. A malformed or missing id must land on a real screen
// saying so, never on a blank one or a crash.
final id = int.tryParse(state.pathParameters['tripId'] ?? '');
if (id == null) {
return const _NotFound();
}
return TripDetailScreen(tripId: id, onBack: () => context.pop());
},
),
],
errorBuilder: (context, state) => const _NotFound(),
);
class _NotFound extends StatelessWidget {
const _NotFound();
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.canPop()
? context.pop()
: context.go(Routes.record),
),
),
body: const Center(child: Text('That ride could not be found.')),
);
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () =>
context.canPop() ? context.pop() : context.go(Routes.record),
),
),
body: const Center(child: Text('That ride could not be found.')),
);
}

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)} · '