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)!;
}
}