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,84 @@
import com.rippr.data.TrackPoint
import com.rippr.data.Segment
import com.rippr.geo.Geo
import com.rippr.geo.LatLon
import com.rippr.stats.RideStatistics
import com.rippr.data.Trip
import com.rippr.data.TripState
import com.rippr.export.RideExport
// A deterministic LCG implemented identically in Kotlin and Dart, so both languages
// see the SAME noise sequence. Neither language's built-in Random can do this.
class Lcg(private var s: Long) {
fun nextDouble(): Double {
s = s * 6364136223846793005L + 1442695040888963407L
val bits = (s ushr 11) and ((1L shl 53) - 1L)
return bits.toDouble() / (1L shl 53).toDouble()
}
}
// FNV-1a 32-bit. String.hashCode() differs between Java and Dart, so it cannot be used
// to compare text across languages -- this can.
fun fnv1a(s: String): Long {
var h = 2166136261L
for (ch in s) { h = h xor ch.code.toLong(); h = (h * 16777619L) and 0xFFFFFFFFL }
return h
}
fun p(seg: Long, ts: Long, lat: Double, lon: Double, sp: Float, alt: Double, id: Long) =
TrackPoint(id = id, tripId = 1, segmentId = seg, timestamp = ts,
latitude = lat, longitude = lon, speedKmh = sp, altitudeM = alt)
fun main() {
fun out(k: String, v: Any) = println("$k=$v")
out("haversine_calgary_edmonton", Geo.haversineMeters(51.0447, -114.0719, 53.5461, -113.4938))
out("haversine_short_hop", Geo.haversineMeters(51.0447, -114.0719, 51.04480, -114.0719))
out("perp_beyond_end", Geo.perpendicularDistanceMeters(
LatLon(51.003, -114.0), LatLon(51.000, -114.0), LatLon(51.002, -114.0)))
val ride = (0 until 21_600).map { LatLon(51.0 + it * 0.00001, -114.0 + Math.sin(it / 100.0) * 0.001) }
val simplified = Geo.simplify(ride, 5.0)
out("simplify_count", simplified.size)
out("simplify_last_lat", simplified.last().lat)
out("path_length_ride", Geo.pathLengthMeters(ride))
// The disputed fixture: 600 stationary fixes with shared LCG +/-8 m altitude noise.
val rng = Lcg(42L)
val noisy = (0 until 600).map {
p(1, it * 1000L, 51.0, -114.0, 0f, 1000.0 + (rng.nextDouble() * 16.0 - 8.0), (it + 1).toLong())
}
out("noisy_gain", RideStatistics.compute(noisy).elevationGainM)
out("noisy_loss", RideStatistics.compute(noisy).elevationLossM)
val climb = (0 until 101).map { p(1, it * 1000L, 51.0 + it * 0.0001, -114.0, 50f, 1000.0 + it, (it + 1).toLong()) }
out("climb_gain", RideStatistics.compute(climb).elevationGainM)
val run = (0 until 100).map { p(1, it * 1000L, 51.0 + it * 0.0001, -114.0, 40f, 1000.0, (it + 1).toLong()) }
val s = RideStatistics.compute(run)
out("run_distance", s.distanceM)
out("run_moving_millis", s.movingMillis)
out("run_avg_speed", s.avgMovingSpeedKmh)
val prof = RideStatistics.elevationProfile(run, 200)
out("profile_size", prof.size)
out("profile_last_distance", prof.last().distanceM)
val hist = RideStatistics.speedHistogram(run, 10)
out("hist_buckets", hist.size)
// --- export byte output -------------------------------------------------
val etrip = Trip(id = 1, startedAt = 1_700_000_000_000L, endedAt = 1_700_000_600_000L,
state = TripState.COMPLETED, distanceM = 12_345.6, maxSpeedKmh = 118.4f)
val esegs = listOf(Segment(id = 1, tripId = 1, startedAt = 0, endedAt = 1),
Segment(id = 2, tripId = 1, startedAt = 2, endedAt = 3))
val epts = esegs.flatMap { sg -> (0 until 5).map {
p(sg.id, 1_700_000_000_000L + it * 1000L, 51.0447 + it * 0.0001, -114.0719, 72f, 1045.0, it.toLong() + 1) } }
val g = RideExport.gpx(etrip, esegs, epts, "Sam & Dave's <ride>")
val j = RideExport.geoJson(etrip, esegs, epts, "Sam & Dave's <ride>")
out("gpx_len", g.length)
out("gpx_fnv", fnv1a(g))
out("geojson_len", j.length)
out("geojson_fnv", fnv1a(j))
out("hist_first_millis", hist.first().millis)
}