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

@@ -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 {