V3-01: activity type per ride

Adds Activity (motorcycle/bicycle/scooter/skateboard/running/walking/other) as
a column on Trip, and threads it through as real behaviour rather than a label:
ActivityProfile (domain/activity_profile.dart) carries a noise floor, accuracy
gate, histogram bucket width, and elevation smoothing window/threshold per
activity, consumed by sanitizeSpeedKmh, isUsableFix, computeSummary, Accumulator
and speedHistogram. The motorcycle profile reproduces the exact constants the
app shipped with before this existed, and a test asserts they never drift apart.

This is the port's first real migration: schemaVersion 1 -> 2,
m.addColumn(trips, trips.activity) with a motorcycle default so every existing
row survives unmodified. Proven with a hand-built v1 SQLite file (raw sqlite3,
not drift_dev's schema tooling) that a real database with real rides upgrades
and keeps every trip, segment and point.

No picker in front of Start: a new trip defaults to whichever activity was most
recently used, derived live from the trips table rather than duplicated into a
Config field. Editable afterwards on trip detail, which recomputes aggregates
under the new profile immediately -- a walking pace that reads as noise under a
motorcycle's floor reads as real movement once the activity is corrected, and
there's a test proving exactly that transition.

The widget-test pass for the activity-picker sheet caught a real overflow bug:
seven options overflowed a Column-based bottom sheet the same way the record
screen once did. Fixed with a scrollable ListView + isScrollControlled, same
shape as that earlier fix.

188 tests (171 -> 188): 2 migration, 5 ActivityProfile, 3 profile-threading
proofs in computeSummary, 5 repository, 2 widget. Analyze clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 14:09:11 -05:00
parent 342a8f8382
commit 6f1bfc753a
20 changed files with 876 additions and 36 deletions

View File

@@ -0,0 +1,64 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:rippr/src/domain/activity_profile.dart';
import 'package:rippr/src/domain/models.dart';
import 'package:rippr/src/telemetry/telemetry.dart';
/// Ported concept check for V3-01: activity type must drive real behaviour, not just a
/// label.
void main() {
test(
'the motorcycle profile reproduces the constants the app shipped with before '
'activity type existed',
() {
// ActivityProfile.motorcycle deliberately duplicates these as literals rather than
// importing them, to avoid a domain-layer dependency cycle -- this is the promised
// check that the two never drift apart silently.
expect(ActivityProfile.motorcycle.noiseFloorKmh, speedNoiseFloorKmh);
expect(ActivityProfile.motorcycle.elevationThresholdM, 3.0);
expect(ActivityProfile.motorcycle.elevationWindowSamples, 15);
expect(ActivityProfile.motorcycle.accuracyGateM, 50.0);
expect(ActivityProfile.motorcycle.histogramBucketKmh, 10);
},
);
test('forActivity covers every Activity value with no fallback surprises', () {
for (final activity in Activity.values) {
// Must not throw -- an unhandled case here would be a runtime crash on a
// brand-new trip, not a compile error, since the switch is exhaustive over the
// enum today but a future Activity value could slip through review.
expect(() => ActivityProfile.forActivity(activity), returnsNormally);
}
});
test('slower activities get a lower noise floor than a motorcycle', () {
final motorcycle = ActivityProfile.forActivity(Activity.motorcycle);
final walking = ActivityProfile.forActivity(Activity.walking);
final running = ActivityProfile.forActivity(Activity.running);
expect(walking.noiseFloorKmh, lessThan(motorcycle.noiseFloorKmh));
expect(running.noiseFloorKmh, lessThan(motorcycle.noiseFloorKmh));
expect(
walking.noiseFloorKmh,
lessThan(running.noiseFloorKmh),
reason: 'a walk is slower than a run, so real movement sits even closer to noise',
);
});
test('running and walking use a finer speed histogram than a motorcycle', () {
// 10 km/h bands are useless for an activity that rarely exceeds 10 km/h at all.
expect(ActivityProfile.forActivity(Activity.running).histogramBucketKmh, 1);
expect(ActivityProfile.forActivity(Activity.walking).histogramBucketKmh, 1);
expect(ActivityProfile.forActivity(Activity.motorcycle).histogramBucketKmh, 10);
});
test('scooter and other fall back to the safest generic profile', () {
expect(
ActivityProfile.forActivity(Activity.scooter),
same(ActivityProfile.motorcycle),
);
expect(
ActivityProfile.forActivity(Activity.other),
same(ActivityProfile.motorcycle),
);
});
}

150
test/migration_test.dart Normal file
View File

@@ -0,0 +1,150 @@
import 'dart:io';
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';
import 'package:sqlite3/sqlite3.dart' as sqlite3;
/// V3-01's migration test — the one that matters more than the feature it belongs to.
///
/// The destructive fallback is gone for good (see the doc comment on [AppDatabase]), so
/// this is the port's first real schema migration, and getting the path right here is
/// what makes every later one trustworthy.
///
/// Builds the v1 schema with raw SQL via `package:sqlite3` directly, rather than
/// drift_dev's schema-versioning tooling — deliberately: that tooling needs a
/// `build.yaml`-declared set of frozen schema snapshots this project has not adopted, and
/// a hand-built v1 table is a faithful enough stand-in. It is copied column-for-column
/// from `Trips`/`Segments`/`TrackPoints` as they were before the `activity` column was
/// added, using the same names — `build.yaml`'s `case_from_dart_to_sql: preserve` means
/// there is no snake_case translation to get wrong.
void main() {
late Directory tempDir;
late File dbFile;
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('rippr_migration_test_');
dbFile = File('${tempDir.path}/v1.sqlite');
});
tearDown(() async {
if (tempDir.existsSync()) tempDir.deleteSync(recursive: true);
});
/// Writes a database file containing exactly the v1 schema, seeded with one trip, one
/// closed segment, and a handful of points -- then closes it, so Drift can open the
/// same file fresh.
void seedV1Database() {
final raw = sqlite3.sqlite3.open(dbFile.path);
raw.execute('''
CREATE TABLE trips (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
startedAt INTEGER NOT NULL,
endedAt INTEGER NULL,
name TEXT NULL,
state TEXT NOT NULL,
distanceM REAL NOT NULL DEFAULT 0,
movingMillis INTEGER NOT NULL DEFAULT 0,
maxSpeedKmh REAL NOT NULL DEFAULT 0,
elevationGainM REAL NOT NULL DEFAULT 0,
pointCount INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_trips_ended ON trips (endedAt);
CREATE TABLE segments (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
tripId INTEGER NOT NULL REFERENCES trips (id) ON DELETE CASCADE,
startedAt INTEGER NOT NULL,
endedAt INTEGER NULL
);
CREATE INDEX idx_segments_trip ON segments (tripId);
CREATE TABLE track_points (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
tripId INTEGER NOT NULL REFERENCES trips (id) ON DELETE CASCADE,
segmentId INTEGER NOT NULL REFERENCES segments (id) ON DELETE CASCADE,
timestamp INTEGER NOT NULL,
latitude REAL NOT NULL,
longitude REAL NOT NULL,
speedKmh REAL NOT NULL,
altitudeM REAL NOT NULL,
accuracyM REAL NOT NULL DEFAULT 0,
bearingDeg REAL NOT NULL DEFAULT 0,
synced INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_points_trip ON track_points (tripId);
CREATE INDEX idx_points_segment ON track_points (segmentId);
CREATE INDEX idx_points_synced ON track_points (synced);
''');
raw.execute('''
INSERT INTO trips (id, startedAt, endedAt, name, state, distanceM, movingMillis,
maxSpeedKmh, elevationGainM, pointCount)
VALUES (1, 1000, 5000, 'Old ride', 'completed', 111.2, 4000, 40.0, 12.0, 3);
''');
raw.execute('''
INSERT INTO segments (id, tripId, startedAt, endedAt) VALUES (1, 1, 1000, 5000);
''');
for (var i = 0; i < 3; i++) {
raw.execute('''
INSERT INTO track_points
(tripId, segmentId, timestamp, latitude, longitude, speedKmh, altitudeM)
VALUES (1, 1, ${1000 + i * 1000}, ${51.0 + i * 0.0001}, -114.0, 40.0, 1000.0);
''');
}
// Drift persists its schema version in SQLite's own user_version pragma. Setting it
// to 1 here is what makes AppDatabase (schemaVersion 2) believe an upgrade is due
// when it opens this file, rather than treating it as a fresh database.
raw.execute('PRAGMA user_version = 1;');
raw.close();
}
test('a v1 database migrates and keeps every ride and point', () async {
seedV1Database();
final db = AppDatabase(NativeDatabase(dbFile));
addTearDown(db.close);
final trip = await db.getTrip(1);
expect(trip, isNotNull, reason: 'the pre-existing ride must survive the migration');
expect(trip!.name, 'Old ride');
expect(trip.distanceM, closeTo(111.2, 1e-9));
expect(trip.pointCount, 3);
expect(
trip.activity,
Activity.motorcycle,
reason: 'a ride recorded before activity existed must default to motorcycle, '
'not be left null or reject the row',
);
final points = await db.pointsForTrip(1);
expect(points.length, 3, reason: 'no point may be lost in the upgrade');
final segments = await db.segmentsForTrip(1);
expect(segments.length, 1);
// The column is genuinely usable afterwards, not just present with a default.
await db.setTripActivity(1, Activity.bicycle);
expect((await db.getTrip(1))!.activity, Activity.bicycle);
});
test('a v1 database with several rides migrates all of them', () async {
seedV1Database();
final raw = sqlite3.sqlite3.open(dbFile.path);
raw.execute('''
INSERT INTO trips (startedAt, endedAt, state, distanceM, pointCount)
VALUES (10000, 15000, 'completed', 500.0, 10),
(20000, 25000, 'completed', 1000.0, 20);
''');
raw.close();
final db = AppDatabase(NativeDatabase(dbFile));
addTearDown(db.close);
final all = await db.watchCompletedTrips().first;
expect(all.length, 3);
expect(all.every((t) => t.activity == Activity.motorcycle), isTrue);
});
}

View File

@@ -1,4 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:rippr/src/domain/activity_profile.dart';
import 'package:rippr/src/domain/models.dart';
import 'package:rippr/src/stats/ride_statistics.dart';
@@ -299,4 +300,57 @@ void main() {
expect(elevationProfile([point(ts: 0)]).length, 1);
});
});
group('activity profile changes real output (V3-01)', () {
test('a slow walk reads as stationary under the motorcycle floor, but not under '
"walking's own", () {
// 20 fixes a second apart, each ~0.7 km/h -- real walking pace, comfortably under
// the motorcycle noise floor (1.5) but above the walking one (0.3). This is the
// whole point of activity profiles: without one, a recorded walk would show
// 00:00:00 moving time despite every fix showing genuine movement.
final points = List.generate(
20,
(i) => point(ts: i * 1000, lat: 51.0 + i * 0.0000065, speed: 0.7),
);
final asMotorcycle =
computeSummary(points, profile: ActivityProfile.motorcycle);
final asWalking = computeSummary(points, profile: ActivityProfile.walking);
expect(asMotorcycle.movingMillis, 0,
reason: 'the motorcycle floor must reject real walking speed as noise');
expect(asWalking.movingMillis, greaterThan(0),
reason: "the walking profile must recognise its own pace as movement");
});
test('elevation window size changes what counts as a real climb', () {
// A short, shallow rise across only a handful of samples. A wider averaging
// window (running/walking) smooths it away as noise; the narrower motorcycle
// window banks more of it as real.
final points = List.generate(
10,
(i) => point(ts: i * 1000, alt: 1000.0 + i * 0.5),
);
final motorcycle =
computeSummary(points, profile: ActivityProfile.motorcycle).elevationGainM;
final running =
computeSummary(points, profile: ActivityProfile.running).elevationGainM;
expect(running, lessThanOrEqualTo(motorcycle),
reason: "running's wider smoothing window must not report MORE gain than "
"motorcycle's narrower one on the same climb");
});
test('defaults to the motorcycle profile when none is given', () {
final points = List.generate(
5,
(i) => point(ts: i * 1000, speed: 40.0),
);
expect(
computeSummary(points),
computeSummary(points, profile: ActivityProfile.motorcycle),
);
});
});
}

View File

@@ -3,6 +3,7 @@ import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:rippr/src/data/database.dart';
import 'package:rippr/src/data/trip_repository.dart';
import 'package:rippr/src/domain/activity_profile.dart';
import 'package:rippr/src/domain/models.dart';
/// Ported from `com.rippr.data.TripRepositoryTest` and `MergeTest`.
@@ -187,6 +188,84 @@ void main() {
});
});
// ---------------------------------------------------------------------------
// Activity type — V3-01
// ---------------------------------------------------------------------------
group('activity', () {
test('a brand new database defaults a fresh trip to motorcycle', () async {
final h = await repo.startTrip(1000);
expect((await repo.tripById(h.tripId))!.activity, Activity.motorcycle);
});
test('starting a new trip defaults to whatever activity was last used, '
'with no picker required', () async {
final first = await repo.startTrip(1000);
await repo.completeTrip(2000);
await repo.setActivity(first.tripId, Activity.walking);
final second = await repo.startTrip(3000);
expect((await repo.tripById(second.tripId))!.activity, Activity.walking,
reason: 'the new ride should inherit the most recently created trip\'s '
'activity, not silently reset to motorcycle');
});
test('an explicit activity overrides the last-used default', () async {
final h = await repo.startTrip(1000, activity: Activity.bicycle);
expect((await repo.tripById(h.tripId))!.activity, Activity.bicycle);
});
test('resuming an active trip never changes its activity', () async {
final h = await repo.startTrip(1000, activity: Activity.running);
await repo.pauseTrip(2000);
// Started again with no explicit activity -- must adopt the paused trip's own,
// not fall back to whatever "last used" would otherwise resolve to.
final resumed = await repo.startTrip(3000);
expect(resumed.tripId, h.tripId);
expect((await repo.tripById(h.tripId))!.activity, Activity.running);
});
test('setActivity recomputes aggregates under the new profile', () async {
// A slow walking pace: under the motorcycle noise floor (1.5 km/h) but above
// walking's own (0.3), so moving time genuinely differs between the two profiles.
final h = await repo.startTrip(1000, activity: Activity.motorcycle);
for (var i = 0; i < 10; i++) {
await addPoint(h, 1000 + i * 1000, speed: 0.7);
}
await repo.completeTrip(11000);
// completeTrip() alone does not recompute aggregates -- the recorder does that
// separately on stop(). Mirror that here rather than relying on a leftover value.
await repo.recomputeAggregates(h.tripId);
final asMotorcycle = (await repo.tripById(h.tripId))!;
expect(asMotorcycle.movingMillis, 0,
reason: 'a motorcycle profile must read a 0.7 km/h pace as noise');
await repo.setActivity(h.tripId, Activity.walking);
final asWalking = (await repo.tripById(h.tripId))!;
expect(asWalking.activity, Activity.walking);
expect(asWalking.movingMillis, greaterThan(0),
reason: 'recomputing under the walking profile must recognise the same '
'fixes as real movement');
// Sanity check that this really did come from ActivityProfile.walking and not a
// coincidence -- computing with the profile explicitly must agree.
expect(
asWalking.movingMillis,
greaterThan(asMotorcycle.movingMillis),
);
// The bicycle/motorcycle-equivalent profile does not exist as a category here;
// just confirm the profile object itself expresses the difference this test
// exercises indirectly through the repository.
expect(
ActivityProfile.walking.noiseFloorKmh,
lessThan(ActivityProfile.motorcycle.noiseFloorKmh),
);
});
});
group('process death', () {
test('a fresh repository over the same database sees the active trip',
() async {

View File

@@ -7,6 +7,7 @@ import 'package:rippr/src/app/providers.dart';
import 'package:rippr/src/data/database.dart';
import 'package:rippr/src/data/trip_repository.dart';
import 'package:rippr/src/domain/models.dart';
import 'package:rippr/src/ui/activity_display.dart';
import 'package:rippr/src/recording/location_source.dart';
import 'package:rippr/src/ui/detail/trip_detail_screen.dart';
import 'package:rippr/src/ui/record/record_screen.dart';
@@ -282,6 +283,27 @@ void main() {
expect(await repo.tripById(a), isNull);
});
screenTest('a tile shows its own activity icon (V3-01)', (tester) async {
final motorcycleTrip =
await seedCompletedTrip(startedAt: 1000, endedAt: 5000, name: 'Ride');
final bikeTrip =
await seedCompletedTrip(startedAt: 10000, endedAt: 15000, name: 'Bike');
await repo.setActivity(bikeTrip, Activity.bicycle);
await tester.pumpWidget(host(const TripsScreen()));
await tester.pumpAndSettle();
Icon iconFor(int tripId) => tester.widget<Icon>(
find.descendant(
of: find.byKey(Key('trip-$tripId')),
matching: find.byType(Icon),
),
);
expect(iconFor(motorcycleTrip).icon, activityIcon(Activity.motorcycle));
expect(iconFor(bikeTrip).icon, activityIcon(Activity.bicycle));
});
});
group('trip detail', () {
@@ -340,5 +362,23 @@ void main() {
expect(find.text('Coast run'), findsOneWidget);
expect((await repo.tripById(id))!.name, 'Coast run');
});
screenTest('editing the activity updates the row and recomputes aggregates',
(tester) async {
final id = await seedCompletedTrip(startedAt: 1000, endedAt: 5000);
await tester.pumpWidget(host(TripDetailScreen(tripId: id), map: false));
await tester.pumpAndSettle();
expect(find.text(activityLabel(Activity.motorcycle)), findsOneWidget);
await tester.tap(find.byKey(const Key('activity-row')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(Key('activity-${Activity.walking.name}')));
await tester.pumpAndSettle();
expect(find.text(activityLabel(Activity.walking)), findsOneWidget);
expect((await repo.tripById(id))!.activity, Activity.walking);
});
});
}