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

122
lib/main.dart Normal file
View File

@@ -0,0 +1,122 @@
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
// This is the theme of your application.
//
// TRY THIS: Try running your application with "flutter run". You'll see
// the application has a purple toolbar. Then, without quitting the app,
// try changing the seedColor in the colorScheme below to Colors.green
// and then invoke "hot reload" (save your changes or press the "hot
// reload" button in a Flutter-supported IDE, or press "r" if you used
// the command line to start the app).
//
// Notice that the counter didn't reset back to zero; the application
// state is not lost during the reload. To reset the state, use hot
// restart instead.
//
// This works for code too, not just values: Most code changes can be
// tested with just a hot reload.
colorScheme: .fromSeed(seedColor: Colors.deepPurple),
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
// This widget is the home page of your application. It is stateful, meaning
// that it has a State object (defined below) that contains fields that affect
// how it looks.
// This class is the configuration for the state. It holds the values (in this
// case the title) provided by the parent (in this case the App widget) and
// used by the build method of the State. Fields in a Widget subclass are
// always marked "final".
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
void _incrementCounter() {
setState(() {
// This call to setState tells the Flutter framework that something has
// changed in this State, which causes it to rerun the build method below
// so that the display can reflect the updated values. If we changed
// _counter without calling setState(), then the build method would not be
// called again, and so nothing would appear to happen.
_counter++;
});
}
@override
Widget build(BuildContext context) {
// This method is rerun every time setState is called, for instance as done
// by the _incrementCounter method above.
//
// The Flutter framework has been optimized to make rerunning build methods
// fast, so that you can just rebuild anything that needs updating rather
// than having to individually change instances of widgets.
return Scaffold(
appBar: AppBar(
// TRY THIS: Try changing the color here to a specific color (to
// Colors.amber, perhaps?) and trigger a hot reload to see the AppBar
// change color while the other colors stay the same.
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
// Here we take the value from the MyHomePage object that was created by
// the App.build method, and use it to set our appbar title.
title: Text(widget.title),
),
body: Center(
// Center is a layout widget. It takes a single child and positions it
// in the middle of the parent.
child: Column(
// Column is also a layout widget. It takes a list of children and
// arranges them vertically. By default, it sizes itself to fit its
// children horizontally, and tries to be as tall as its parent.
//
// Column has various properties to control how it sizes itself and
// how it positions its children. Here we use mainAxisAlignment to
// center the children vertically; the main axis here is the vertical
// axis because Columns are vertical (the cross axis would be
// horizontal).
//
// TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint"
// action in the IDE, or press "p" in the console), to see the
// wireframe for each widget.
mainAxisAlignment: .center,
children: [
const Text('You have pushed the button this many times:'),
Text(
'$_counter',
style: Theme.of(context).textTheme.headlineMedium,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}

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