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:
213
lib/src/ui/components/ride_map.dart
Normal file
213
lib/src/ui/components/ride_map.dart
Normal 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)!;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user