Scaffold Flutter port: toolchain, project, and Geo

T00 — toolchain green on both platforms. flutter doctor reports no issues.
Pinned Flutter to Temurin 21 (AGP rejects the default JDK 25, same constraint
as the native build) and installed Android cmdline-tools with an explicit
--sdk_root, the trap already documented in the native DEVELOPMENT.md.
No caches were deleted: the 16 GiB disk reading that drove the cleanup plan
re-measured at 36 GiB before anything was removed.

T01 — flutter create for android+ios. applicationId is com.rippr.port, not
com.rippr, so the native app stays installable alongside it during the port;
T27 switches it at cutover.

T02 — geo/geo.dart ported from com.rippr.geo.Geo with all 23 tests, tolerances
and comments carried over unchanged. Kotlin's object namespace became top-level
functions; LatLon stays our own type so the pure layer never depends on
flutter_map.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 20:13:15 -05:00
commit 725e6f63b7
73 changed files with 4141 additions and 0 deletions

187
lib/src/geo/geo.dart Normal file
View File

@@ -0,0 +1,187 @@
/// Geographic maths with no Flutter or platform dependencies, so it is testable
/// on the plain Dart VM.
///
/// Ported from `com.rippr.geo.Geo` (native Android v2.0.1). The structure is kept
/// deliberately faithful to the Kotlin original so the two can be diffed and so the
/// cross-language parity harness can assert identical output.
///
/// Haversine rather than Vincenty throughout: assuming a sphere costs about 0.5% — a
/// few metres per kilometre — which is well below GPS noise. Vincenty's iterative
/// solution would be false precision at real cost.
library;
import 'dart:math' as math;
/// A bare latitude/longitude pair.
///
/// Deliberately *not* `latlong2`'s `LatLng`: the pure layer must not depend on the
/// map package. Conversion happens at the rendering boundary only.
class LatLon {
const LatLon(this.lat, this.lon);
final double lat;
final double lon;
@override
bool operator ==(Object other) =>
other is LatLon && other.lat == lat && other.lon == lon;
@override
int get hashCode => Object.hash(lat, lon);
@override
String toString() => 'LatLon($lat, $lon)';
}
class Bounds {
const Bounds(this.minLat, this.minLon, this.maxLat, this.maxLon);
final double minLat;
final double minLon;
final double maxLat;
final double maxLon;
double get centerLat => (minLat + maxLat) / 2;
double get centerLon => (minLon + maxLon) / 2;
/// True when every point sits at effectively one spot — a parked "ride".
bool get isDegenerate =>
(maxLat - minLat).abs() < 1e-9 && (maxLon - minLon).abs() < 1e-9;
@override
String toString() => 'Bounds($minLat, $minLon, $maxLat, $maxLon)';
}
/// IUGG mean Earth radius.
const double earthRadiusM = 6371008.8;
const double _degToRad = math.pi / 180.0;
double haversineMeters(double lat1, double lon1, double lat2, double lon2) {
final dLat = (lat2 - lat1) * _degToRad;
final dLon = (lon2 - lon1) * _degToRad;
final sinLat = math.sin(dLat / 2);
final sinLon = math.sin(dLon / 2);
final a = sinLat * sinLat +
math.cos(lat1 * _degToRad) * math.cos(lat2 * _degToRad) * sinLon * sinLon;
// asin(sqrt(a)) rather than atan2 — better conditioned for the very short hops
// between consecutive GPS fixes.
return 2 * earthRadiusM * math.asin(math.min(1.0, math.sqrt(a)));
}
double haversineBetween(LatLon a, LatLon b) =>
haversineMeters(a.lat, a.lon, b.lat, b.lon);
/// Douglas–Peucker simplification, tolerance in **metres**.
///
/// A degree-based tolerance would behave differently depending where you ride — a
/// degree of longitude is ~111 km at the equator and ~0 at the poles.
///
/// Iterative with an explicit stack: a 20,000-point ride can blow the call stack in
/// the pathological case where recursion never balances.
///
/// This is a **render-only** transform. It must never reach storage or export.
List<LatLon> simplify(List<LatLon> points, double epsilonMeters) {
if (points.length <= 2 || epsilonMeters <= 0.0) return points;
final lastIndex = points.length - 1;
final keep = List<bool>.filled(points.length, false);
keep[0] = true;
keep[lastIndex] = true;
final stack = <List<int>>[
[0, lastIndex],
];
while (stack.isNotEmpty) {
final pair = stack.removeLast();
final first = pair[0];
final last = pair[1];
if (last <= first + 1) continue;
var maxDist = 0.0;
var index = first;
for (var i = first + 1; i < last; i++) {
final d = perpendicularDistanceMeters(points[i], points[first], points[last]);
if (d > maxDist) {
maxDist = d;
index = i;
}
}
if (maxDist > epsilonMeters) {
keep[index] = true;
stack.add([first, index]);
stack.add([index, last]);
}
}
final out = <LatLon>[];
for (var i = 0; i < points.length; i++) {
if (keep[i]) out.add(points[i]);
}
return out;
}
/// Distance from [p] to the segment [a]–[b], via a local equirectangular projection.
///
/// Valid over the short spans between consecutive fixes and far cheaper than a
/// geodesic solution.
double perpendicularDistanceMeters(LatLon p, LatLon a, LatLon b) {
final latRef = (a.lat + b.lat) / 2 * _degToRad;
final mPerDegLat = earthRadiusM * _degToRad;
final mPerDegLon = mPerDegLat * math.cos(latRef);
final ax = a.lon * mPerDegLon;
final ay = a.lat * mPerDegLat;
final bx = b.lon * mPerDegLon;
final by = b.lat * mPerDegLat;
final px = p.lon * mPerDegLon;
final py = p.lat * mPerDegLat;
final dx = bx - ax;
final dy = by - ay;
final lengthSq = dx * dx + dy * dy;
if (lengthSq == 0.0) {
// Degenerate segment: fall back to point distance.
final ex = px - ax;
final ey = py - ay;
return math.sqrt(ex * ex + ey * ey);
}
// Clamped projection, so a point beyond either end measures to the endpoint
// rather than to the infinite line.
final t = (((px - ax) * dx + (py - ay) * dy) / lengthSq).clamp(0.0, 1.0);
final cx = ax + t * dx;
final cy = ay + t * dy;
final ex = px - cx;
final ey = py - cy;
return math.sqrt(ex * ex + ey * ey);
}
/// Null for an empty list — callers must handle "no path" rather than centring on
/// Null Island.
Bounds? bounds(List<LatLon> points) {
if (points.isEmpty) return null;
var minLat = points[0].lat;
var maxLat = points[0].lat;
var minLon = points[0].lon;
var maxLon = points[0].lon;
for (final p in points) {
minLat = math.min(minLat, p.lat);
maxLat = math.max(maxLat, p.lat);
minLon = math.min(minLon, p.lon);
maxLon = math.max(maxLon, p.lon);
}
return Bounds(minLat, minLon, maxLat, maxLon);
}
/// Total length of a polyline. Callers must not span a pause with this.
double pathLengthMeters(List<LatLon> points) {
if (points.length < 2) return 0.0;
var total = 0.0;
for (var i = 1; i < points.length; i++) {
total += haversineBetween(points[i - 1], points[i]);
}
return total;
}