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,207 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:rippr/src/domain/models.dart';
import 'package:rippr/src/export/ride_export.dart';
import 'package:xml/xml.dart';
/// Ported from `com.rippr.export.RideExportTest`.
///
/// Like the Kotlin original, this parses the output with a **real** parser rather than
/// matching substrings. Structural validity is the whole point: a malformed export fails
/// invisibly until some other tool rejects the file.
void main() {
var nextId = 1;
const trip = Trip(
id: 1,
startedAt: 1700000000000,
endedAt: 1700000600000,
state: TripState.completed,
distanceM: 12345.6,
maxSpeedKmh: 118.4,
);
TrackPoint point(int segmentId, int i) => TrackPoint(
id: nextId++,
tripId: 1,
segmentId: segmentId,
timestamp: 1700000000000 + i * 1000,
latitude: 51.0447 + i * 0.0001,
longitude: -114.0719,
speedKmh: 72.0,
altitudeM: 1045.0,
);
({List<Segment> segments, List<TrackPoint> points, int total}) ride(
{required int segmentCount, required int perSegment}) {
final segments = List.generate(
segmentCount,
(i) => Segment(id: i + 1, tripId: 1, startedAt: 0, endedAt: 1),
);
final points = <TrackPoint>[
for (final s in segments)
for (var i = 0; i < perSegment; i++) point(s.id, i),
];
return (
segments: segments,
points: points,
total: segmentCount * perSegment
);
}
setUp(() => nextId = 1);
group('gpx', () {
test('gpx is well-formed and carries the expected structure', () {
final r = ride(segmentCount: 2, perSegment: 5);
final doc =
XmlDocument.parse(gpx(trip, r.segments, r.points, 'Morning loop'));
expect(doc.rootElement.name.local, 'gpx');
expect(doc.rootElement.getAttribute('version'), '1.1');
expect(doc.findAllElements('trk').length, 1);
});
test('one trkseg per segment, so pauses survive the round trip', () {
final r = ride(segmentCount: 3, perSegment: 4);
final doc = XmlDocument.parse(gpx(trip, r.segments, r.points, 'Ride'));
expect(doc.findAllElements('trkseg').length, 3);
});
test('every raw point is exported with no decimation', () {
final r = ride(segmentCount: 2, perSegment: 250);
final doc = XmlDocument.parse(gpx(trip, r.segments, r.points, 'Ride'));
expect(doc.findAllElements('trkpt').length, r.total,
reason: 'decimation must never reach an export');
});
test('timestamps are ISO 8601 in UTC', () {
final r = ride(segmentCount: 1, perSegment: 1);
final doc = XmlDocument.parse(gpx(trip, r.segments, r.points, 'Ride'));
final time = doc.findAllElements('time').elementAt(1).innerText;
expect(time, '2023-11-14T22:13:20Z');
expect(time.endsWith('Z'), isTrue, reason: 'must be UTC with a Z suffix');
});
test('coordinates and elevation land on the right attributes', () {
final r = ride(segmentCount: 1, perSegment: 1);
final doc = XmlDocument.parse(gpx(trip, r.segments, r.points, 'Ride'));
final trkpt = doc.findAllElements('trkpt').first;
expect(trkpt.getAttribute('lat'), '51.0447000');
expect(trkpt.getAttribute('lon'), '-114.0719000');
expect(doc.findAllElements('ele').first.innerText, '1045.0');
});
test('speed is exported in metres per second', () {
final r = ride(segmentCount: 1, perSegment: 1);
final doc = XmlDocument.parse(gpx(trip, r.segments, r.points, 'Ride'));
// 72 km/h is exactly 20 m/s.
expect(doc.findAllElements('speed').first.innerText, '20.0');
});
test('a hostile trip name still produces valid xml', () {
final r = ride(segmentCount: 1, perSegment: 2);
const nasty = 'Sam & Dave\'s <ride> "fast" ';
// Would throw if the name were interpolated unescaped.
final doc = XmlDocument.parse(gpx(trip, r.segments, r.points, nasty));
expect(doc.findAllElements('name').first.innerText, nasty);
});
test('escaping covers every xml metacharacter', () {
expect(escapeXml('&<>"\''), '&amp;&lt;&gt;&quot;&apos;');
});
test('an empty ride still produces a valid document', () {
final doc = XmlDocument.parse(gpx(trip, const [], const [], 'Empty'));
expect(doc.rootElement.name.local, 'gpx');
expect(doc.findAllElements('trkpt').length, 0);
expect(doc.findAllElements('trkseg').length, 0);
});
test('points whose segment is missing are still exported', () {
final orphan = [point(99, 0)];
final doc = XmlDocument.parse(gpx(trip, const [], orphan, 'Ride'));
expect(doc.findAllElements('trkpt').length, 1,
reason: 'no point may be silently dropped');
});
});
group('geojson', () {
test('geojson coordinates are longitude first', () {
final r = ride(segmentCount: 1, perSegment: 1);
final json = jsonDecode(geoJson(trip, r.segments, r.points, 'Ride'))
as Map<String, dynamic>;
final coords = ((((json['features'] as List).first
as Map<String, dynamic>)['geometry']
as Map<String, dynamic>)['coordinates'] as List).first as List;
expect(coords[0] as double, closeTo(-114.0719, 1e-6),
reason: 'longitude must come first');
expect(coords[1] as double, closeTo(51.0447, 1e-6));
expect(coords[2] as double, closeTo(1045.0, 1e-6));
});
test('geojson has one LineString feature per segment', () {
final r = ride(segmentCount: 3, perSegment: 4);
final json = jsonDecode(geoJson(trip, r.segments, r.points, 'Ride'))
as Map<String, dynamic>;
final features = json['features'] as List;
expect(features.length, 3);
expect(
((features.first as Map<String, dynamic>)['geometry']
as Map<String, dynamic>)['type'],
'LineString');
});
test('geojson exports every raw point', () {
final r = ride(segmentCount: 2, perSegment: 120);
final json = jsonDecode(geoJson(trip, r.segments, r.points, 'Ride'))
as Map<String, dynamic>;
var exported = 0;
for (final f in json['features'] as List) {
exported += (((f as Map<String, dynamic>)['geometry']
as Map<String, dynamic>)['coordinates'] as List).length;
}
expect(exported, r.total);
});
test('a hostile name is escaped in json too', () {
final r = ride(segmentCount: 1, perSegment: 1);
final json = jsonDecode(
geoJson(trip, r.segments, r.points, r'He said "go" \ fast'))
as Map<String, dynamic>;
expect((json['properties'] as Map<String, dynamic>)['name'],
r'He said "go" \ fast');
});
test('geojson of an empty ride parses', () {
final json = jsonDecode(geoJson(trip, const [], const [], 'Empty'))
as Map<String, dynamic>;
expect((json['features'] as List).length, 0);
});
});
}