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,248 @@
import 'package:drift/drift.dart' show driftRuntimeOptions;
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:rippr/src/data/database.dart';
import 'package:rippr/src/domain/models.dart';
/// Ported from `com.rippr.data.SchemaTest`.
///
/// **These were instrumented tests requiring a device.** Against Drift on the Dart VM
/// they are plain unit tests: faster, and runnable with no emulator booted. That is one
/// of the concrete wins of the port.
///
/// Every test uses an in-memory database. The native suite once wiped a real device's
/// rides by running against the production singleton in `setUp`, so this is not
/// negotiable — see `docs/TESTING.md` in the native repo.
void main() {
late AppDatabase db;
setUp(() {
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
db = AppDatabase(NativeDatabase.memory());
});
tearDown(() async => db.close());
Future<(int tripId, int segmentId)> seedTrip({int startedAt = 1000}) async {
final tripId =
await db.insertTrip(Trip(startedAt: startedAt, state: TripState.recording));
final segmentId = await db.insertSegment(tripId, startedAt);
return (tripId, segmentId);
}
TrackPoint point(int tripId, int segmentId, int ts, {double speed = 50.0}) =>
TrackPoint(
tripId: tripId,
segmentId: segmentId,
timestamp: ts,
latitude: 51.0447,
longitude: -114.0719,
speedKmh: speed,
altitudeM: 1045.0,
);
group('cascade', () {
test('deleting a trip cascades to segments and points', () async {
final (tripId, segmentId) = await seedTrip();
await db.insertPoints(
List.generate(5, (i) => point(tripId, segmentId, 1000 + i)));
expect(await db.countPointsForTrip(tripId), 5);
expect(await db.countSegmentsForTrip(tripId), 1);
await db.deleteTrip(tripId);
expect(await db.countPointsForTrip(tripId), 0);
expect(await db.countSegmentsForTrip(tripId), 0);
expect(await db.getTrip(tripId), isNull);
});
test('foreign keys are actually enforced', () async {
// SQLite defaults foreign_keys to OFF. Room enabled it; Drift does not, so the
// pragma in beforeOpen is load-bearing. Without it the CASCADE above is
// decorative and every delete silently orphans its points.
final result =
await db.customSelect('PRAGMA foreign_keys').getSingle();
expect(result.data.values.first, 1,
reason: 'foreign_keys pragma is off — CASCADE would do nothing');
});
test('a point cannot reference a trip that does not exist', () async {
await expectLater(
db.insertPoint(point(9999, 9999, 1000)),
throwsA(anything),
);
});
});
group('active trip', () {
test('an open trip is the active one and a closed trip is not', () async {
final (tripId, _) = await seedTrip();
expect((await db.getActiveTrip())?.id, tripId);
await db.closeTrip(tripId, 5000);
expect(await db.getActiveTrip(), isNull);
});
test('watchActiveTrip emits the open trip', () async {
final (tripId, _) = await seedTrip();
expect((await db.watchActiveTrip().first)?.id, tripId);
});
test('completed trips are ordered newest first', () async {
final a = await db.insertTrip(
const Trip(startedAt: 1000, state: TripState.recording));
final b = await db.insertTrip(
const Trip(startedAt: 3000, state: TripState.recording));
final c = await db.insertTrip(
const Trip(startedAt: 2000, state: TripState.recording));
for (final id in [a, b, c]) {
await db.closeTrip(id, 9000);
}
final listed = await db.watchCompletedTrips().first;
expect(listed.map((t) => t.startedAt).toList(), [3000, 2000, 1000]);
});
});
group('point ordering', () {
test('points come back by segment then id, not by timestamp', () async {
// Timestamps are GPS-derived and can jump; id is monotonic in write order. The
// ordering contract is what keeps a ride walkable with pauses intact.
final (tripId, seg1) = await seedTrip();
final seg2 = await db.insertSegment(tripId, 2000);
await db.insertPoints([
point(tripId, seg2, 500), // deliberately the earliest timestamp
point(tripId, seg1, 9000),
point(tripId, seg1, 1000),
]);
final ordered = await db.pointsForTrip(tripId);
expect(ordered.map((p) => p.segmentId).toList(), [seg1, seg1, seg2]);
expect(ordered.first.timestamp, 9000,
reason: 'must not be re-sorted by timestamp');
});
test('lastInSegment returns the highest id, the recorder anchor', () async {
final (tripId, segmentId) = await seedTrip();
await db.insertPoints(
List.generate(5, (i) => point(tripId, segmentId, 1000 + i)));
final last = await db.lastInSegment(segmentId);
expect(last!.timestamp, 1004);
});
test('openSegment finds the one still being recorded into', () async {
final (tripId, seg1) = await seedTrip();
await db.closeSegment(seg1, 2000);
final seg2 = await db.insertSegment(tripId, 3000);
expect((await db.openSegment(tripId))?.id, seg2);
await db.closeSegment(seg2, 4000);
expect(await db.openSegment(tripId), isNull);
});
});
group('stats guards', () {
test('a trip with no points reports zeros rather than failing', () async {
final (tripId, _) = await seedTrip();
final stats = await db.watchTripStats(tripId).first;
expect(stats.pointCount, 0);
expect(stats.maxSpeedKmh, 0.0);
expect(stats.avgSpeedKmh, 0.0);
expect(stats.firstTimestamp, 0);
expect(stats.durationMillis, 0);
});
test('stats aggregate over the trip', () async {
final (tripId, segmentId) = await seedTrip();
await db.insertPoints([
point(tripId, segmentId, 1000, speed: 10),
point(tripId, segmentId, 2000, speed: 50),
point(tripId, segmentId, 3000, speed: 30),
]);
final stats = await db.watchTripStats(tripId).first;
expect(stats.pointCount, 3);
expect(stats.maxSpeedKmh, 50.0);
expect(stats.avgSpeedKmh, closeTo(30.0, 1e-9));
expect(stats.durationMillis, 2000);
expect(stats.pendingUpload, 3);
});
});
group('upload backlog', () {
test('unsynced points come back oldest first and mark cleanly', () async {
final (tripId, segmentId) = await seedTrip();
await db.insertPoints(
List.generate(10, (i) => point(tripId, segmentId, 1000 + i)));
expect(await db.countUnsynced(), 10);
final batch = await db.unsyncedPoints(4);
expect(batch.length, 4);
expect(batch.first.timestamp, 1000);
await db.markSynced(batch.map((p) => p.id).toList());
expect(await db.countUnsynced(), 6);
expect((await db.unsyncedPoints(1)).first.timestamp, 1004);
});
test('marking an empty list is a no-op', () async {
final (tripId, segmentId) = await seedTrip();
await db.insertPoints([point(tripId, segmentId, 1000)]);
await db.markSynced(const []);
expect(await db.countUnsynced(), 1);
});
});
group('aggregates', () {
test('updateAggregates writes only the running totals', () async {
final (tripId, _) = await seedTrip();
await db.renameTrip(tripId, 'Morning loop');
await db.updateAggregates(
id: tripId,
distanceM: 1234.5,
movingMillis: 60000,
maxSpeedKmh: 88.5,
elevationGainM: 42.0,
pointCount: 99,
);
final trip = (await db.getTrip(tripId))!;
expect(trip.distanceM, 1234.5);
expect(trip.movingMillis, 60000);
expect(trip.maxSpeedKmh, 88.5);
expect(trip.elevationGainM, 42.0);
expect(trip.pointCount, 99);
expect(trip.name, 'Morning loop', reason: 'unrelated columns must survive');
expect(trip.endedAt, isNull, reason: 'must not close the trip');
});
test('state round-trips through the enum column', () async {
final (tripId, _) = await seedTrip();
await db.setTripState(tripId, TripState.paused);
expect((await db.getTrip(tripId))!.state, TripState.paused);
await db.closeTrip(tripId, 5000);
expect((await db.getTrip(tripId))!.state, TripState.completed);
});
test('renaming to null clears the name rather than storing empty', () async {
final (tripId, _) = await seedTrip();
await db.renameTrip(tripId, 'Something');
await db.renameTrip(tripId, null);
expect((await db.getTrip(tripId))!.name, isNull);
});
});
}