Add the Rippr Flutter port: source, history bundle, and installable APK

The port runs on Android and iOS and is feature-complete; the native Android app
is superseded but kept, since it is still the only version that has recorded real
rides.

rippr-flutter-1.0-debug.apk is package com.rippr.port, deliberately different
from the native com.rippr so both install side by side. Recording the same ride
on both at once is the strongest available check that the port is faithful.

Added INSTALL.md covering both platforms. Android is a one-line adb install; iOS
has no APK equivalent and must be built and signed through Xcode with a free
Apple ID, which gives a 7-day profile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 22:37:45 -05:00
parent 64f5dff30b
commit 0bc42b2e5a
98 changed files with 8417 additions and 24 deletions

View File

@@ -0,0 +1,302 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:rippr/src/domain/models.dart';
import 'package:rippr/src/stats/ride_statistics.dart';
/// Ported from `com.rippr.stats.RideStatisticsTest`.
///
/// ## One case cannot be a literal port: the noise fixture
///
/// The Kotlin original seeds `kotlin.random.Random(42)`. Dart's `Random(42)` is a
/// different generator and produces a different sequence, so this suite cannot assert
/// the same *number* — only the same *bound*. That is fine here, because the assertion
/// was always a regression guard rather than an accuracy claim: a naive implementation
/// reported 1498 m over a parked bike, and anything in that neighbourhood must fail.
///
/// T07's cross-language parity harness must therefore drive elevation from a shared,
/// language-independent fixture rather than from either language's RNG.
/// A deterministic linear congruential generator, implemented identically in Kotlin and
/// Dart so both languages can be driven by the *same* noise sequence. Neither language's
/// built-in `Random` can do this — that is the whole reason this exists.
///
/// Dart ints are 64-bit two's complement on the VM and multiplication wraps, matching
/// Kotlin's `Long`. The Kotlin twin lives in `tool/parity/main.kt`.
class _Lcg {
_Lcg(this._s);
int _s;
double nextDouble() {
_s = _s * 6364136223846793005 + 1442695040888963407;
final bits = (_s >>> 11) & ((1 << 53) - 1);
return bits / (1 << 53);
}
}
void main() {
var nextId = 1;
TrackPoint point({
int segmentId = 1,
required int ts,
double lat = 51.0,
double lon = -114.0,
double speed = 50.0,
double alt = 1000.0,
}) =>
TrackPoint(
id: nextId++,
tripId: 1,
segmentId: segmentId,
timestamp: ts,
latitude: lat,
longitude: lon,
speedKmh: speed,
altitudeM: alt,
);
/// A straight northward run: [n] fixes one second apart, 0.0001° (~11 m) each.
List<TrackPoint> straightRun(int n,
{int segmentId = 1, int startTs = 0, double speed = 40.0}) =>
List.generate(
n,
(i) => point(
segmentId: segmentId,
ts: startTs + i * 1000,
lat: 51.0 + i * 0.0001,
speed: speed),
);
setUp(() => nextId = 1);
group('distance', () {
test('distance matches the summed haversine hops', () {
final summary = computeSummary(straightRun(11));
// Ten hops of 0.0001 degrees latitude, ~11.12 m each.
expect(summary.distanceM, closeTo(111.2, 3.0));
});
test('distance never spans a pause', () {
// Two segments 100 km apart — a rider who trailered between them.
final first = straightRun(5, segmentId: 1, startTs: 0);
final second = List.generate(
5,
(i) => point(segmentId: 2, ts: 600000 + i * 1000, lat: 52.0 + i * 0.0001),
);
final summary = computeSummary([...first, ...second]);
// Two runs of ~44.5 m each; the ~111 km gap must not appear.
expect(summary.distanceM, lessThan(200.0),
reason: 'gap leaked into distance: ${summary.distanceM} m');
});
test('single point has zero distance and no NaN', () {
final summary = computeSummary([point(ts: 0)]);
expect(summary.distanceM, closeTo(0.0, 1e-9));
expect(summary.avgMovingSpeedKmh, closeTo(0.0, 1e-9));
expect(summary.pointCount, 1);
});
test('empty input returns the zero summary', () {
expect(computeSummary(const []), RideSummary.empty);
});
});
group('elevation — the regression that matters most', () {
test('stationary noisy altitude yields near-zero elevation gain', () {
// A parked bike for ten minutes with realistic +/-8 m GPS altitude wander.
//
// A naive "sum every delta over the threshold" implementation reported **1498 m**
// of climbing on this shape of input. Smoothing plus reversal hysteresis is what
// brings it down. This bound is a regression guard against that class of failure,
// not an accuracy claim.
//
// ## Why a hand-rolled LCG instead of Random(42)
//
// The Kotlin original seeded `kotlin.random.Random(42)`, which produces a
// different stream from Dart's `Random(42)` — so the two suites could never be
// compared, only vaguely trusted. Driving both from the identical LCG in
// `tool/parity/` proved the port is exact: **38.959594555022136 m in both
// languages, to the last digit.**
//
// That also exposed something about the native app: on a shared fixture this
// algorithm yields ~39 m, which would **fail Kotlin's own 35 m bound**. The
// Kotlin test passes on seed luck, not on a property of the algorithm. A sweep of
// 25 Dart seeds ranged 24.7–46.7 m (median 36). The bound below is therefore set
// from measured behaviour with headroom, rather than inherited from a lucky draw.
final rng = _Lcg(42);
final points = List.generate(
600,
(i) => point(
ts: i * 1000, speed: 0.0, alt: 1000.0 + (rng.nextDouble() * 16.0 - 8.0)),
);
final summary = computeSummary(points);
expect(summary.elevationGainM, closeTo(38.959594555022136, 1e-9),
reason: 'must stay bit-identical to the Kotlin implementation');
expect(summary.elevationGainM, lessThan(60.0),
reason:
'phantom climbing: ${summary.elevationGainM} m over a parked bike');
});
test('a genuine climb is recorded', () {
final points = List.generate(
101,
(i) => point(ts: i * 1000, lat: 51.0 + i * 0.0001, alt: 1000.0 + i),
);
final summary = computeSummary(points);
expect(summary.elevationGainM, closeTo(100.0, 5.0));
expect(summary.elevationLossM, closeTo(0.0, 5.0));
});
test('a descent counts as loss, not gain', () {
final points = List.generate(
101,
(i) => point(ts: i * 1000, lat: 51.0 + i * 0.0001, alt: 1100.0 - i),
);
final summary = computeSummary(points);
expect(summary.elevationLossM, closeTo(100.0, 5.0));
expect(summary.elevationGainM, closeTo(0.0, 5.0));
});
test('an out-and-back records both gain and loss', () {
final up = List.generate(51, (i) => point(ts: i * 1000, alt: 1000.0 + i));
final down =
List.generate(51, (i) => point(ts: 51000 + i * 1000, alt: 1050.0 - i));
final summary = computeSummary([...up, ...down]);
expect(summary.elevationGainM, closeTo(50.0, 5.0));
expect(summary.elevationLossM, closeTo(50.0, 5.0));
});
});
group('moving vs elapsed time', () {
test('moving time excludes time below the speed noise floor', () {
final moving = List.generate(60, (i) => point(ts: i * 1000, speed: 40.0));
final stopped =
List.generate(60, (i) => point(ts: (60 + i) * 1000, speed: 0.0));
final summary = computeSummary([...moving, ...stopped]);
expect(summary.movingMillis.toDouble(), closeTo(59000.0, 2000.0),
reason: '~59 s of movement');
expect(summary.elapsedMillis, 119000);
expect(summary.stoppedMillis, greaterThan(55000));
});
test('a long gap between fixes does not inject phantom moving time', () {
// Two fixes two minutes apart — a tunnel. Without the cap this would count as
// 120 s of movement at the last known speed.
final points = [
point(ts: 0, speed: 90.0),
point(ts: 120000, lat: 51.001, speed: 90.0),
];
expect(computeSummary(points).movingMillis, 0);
});
test('elapsed time prefers closed segment spans when available', () {
final points = straightRun(5, startTs: 1000);
const segments = [Segment(id: 1, tripId: 1, startedAt: 0, endedAt: 10000)];
expect(computeSummary(points, segments: segments).elapsedMillis, 10000);
});
test('elapsed falls back to point timestamps when a segment is still open', () {
final points = straightRun(5, startTs: 1000); // 1000..5000
const segments = [Segment(id: 1, tripId: 1, startedAt: 0)];
expect(computeSummary(points, segments: segments).elapsedMillis, 4000);
});
});
group('average speed', () {
test('average speed uses distance over moving time, not the sample mean', () {
// 100 fixes 1 s apart, each 0.0001 deg (~11.12 m) => ~1101 m over ~99 s
// => ~40 km/h.
final points = List.generate(
100,
(i) => point(ts: i * 1000, lat: 51.0 + i * 0.0001, speed: 40.0),
);
final summary = computeSummary(points);
expect(summary.avgMovingSpeedKmh, closeTo(40.0, 2.0));
});
test('average speed is zero rather than NaN when nothing moved', () {
final points = List.generate(10, (i) => point(ts: i * 1000, speed: 0.0));
final summary = computeSummary(points);
expect(summary.avgMovingSpeedKmh, closeTo(0.0, 1e-9));
expect(summary.avgMovingSpeedKmh.isNaN, isFalse,
reason: 'NaN would render literally on screen');
});
test('max speed is the highest sample', () {
final points = [
point(ts: 0, speed: 40.0),
point(ts: 1000, speed: 118.4),
point(ts: 2000, speed: 60.0),
];
expect(computeSummary(points).maxSpeedKmh, closeTo(118.4, 0.001));
});
});
group('histogram', () {
test('histogram measures time in band, not sample count', () {
final points = [
point(ts: 0, speed: 5.0),
point(ts: 1000, speed: 15.0), // 1 s in 10-20
point(ts: 2000, speed: 15.0), // 1 s in 10-20
point(ts: 3000, speed: 95.0), // 1 s in 90-100
];
final buckets = speedHistogram(points, bucketKmh: 10);
expect(buckets.firstWhere((b) => b.fromKmh == 10).millis, 2000);
expect(buckets.firstWhere((b) => b.fromKmh == 90).millis, 1000);
});
test('histogram is empty for degenerate input', () {
expect(speedHistogram(const []), isEmpty);
expect(speedHistogram([point(ts: 0)]), isEmpty);
});
});
group('elevation profile', () {
test('profile plots altitude against cumulative distance', () {
final points = List.generate(
5,
(i) => point(ts: i * 1000, lat: 51.0 + i * 0.0001, alt: 1000.0 + i * 10),
);
final profile = elevationProfile(points);
expect(profile.length, 5);
expect(profile.first.distanceM, closeTo(0.0, 1e-9));
expect(profile.last.altitudeM, closeTo(1040.0, 1e-9));
expect(profile.last.distanceM, greaterThan(profile.first.distanceM),
reason: 'distance must increase');
});
test('profile does not accrue distance across a pause', () {
final first = List.generate(
3, (i) => point(segmentId: 1, ts: i * 1000, lat: 51.0 + i * 0.0001));
final second = List.generate(
3,
(i) => point(segmentId: 2, ts: 600000 + i * 1000, lat: 52.0 + i * 0.0001),
);
final profile = elevationProfile([...first, ...second]);
expect(profile.last.distanceM, lessThan(200.0),
reason:
'the 111 km gap leaked into the profile: ${profile.last.distanceM}');
});
test('profile downsamples a long ride', () {
final points = List.generate(
21600,
(i) => point(ts: i * 500, lat: 51.0 + i * 0.00001, alt: 1000.0),
);
final profile = elevationProfile(points, maxSamples: 200);
expect(profile.length, lessThanOrEqualTo(201),
reason: 'expected ~200 samples, got ${profile.length}');
expect(profile.last.altitudeM, closeTo(points.last.altitudeM, 1e-9));
});
test('profile handles empty and single-point input', () {
expect(elevationProfile(const []), isEmpty);
expect(elevationProfile([point(ts: 0)]).length, 1);
});
});
}