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

@@ -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),
);
});
});
}