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,112 @@
/// Per-activity defaults for the recording and statistics pipeline.
///
/// The real value of activity type is not the label — it is driving behaviour that was
/// previously hardcoded for a motorcycle: the noise floor that separates a real fix from
/// GPS jitter, how fine the speed histogram is, how forgiving the accuracy gate is, and
/// how much smoothing elevation gets. See V3-01 in `docs/v3/`.
///
/// These are **starting points, not tuned constants**. V3-13 (real-ride measurement) is
/// where they get corrected against real data, once there is real data to correct them
/// against — the same discipline the elevation accumulator has always used: do not tune
/// blind.
library;
import 'models.dart';
class ActivityProfile {
const ActivityProfile({
required this.noiseFloorKmh,
required this.histogramBucketKmh,
required this.accuracyGateM,
required this.elevationWindowSamples,
required this.elevationThresholdM,
});
/// Below this, a fix is treated as noise rather than movement. See
/// `telemetry.sanitizeSpeedKmh`.
final double noiseFloorKmh;
/// Width of each bar in the speed-distribution chart, in km/h.
final int histogramBucketKmh;
/// Fixes worse than this are dropped rather than recorded. See
/// `telemetry.isUsableFix`.
final double accuracyGateM;
/// Samples averaged before an elevation change is judged real rather than GPS wander.
final int elevationWindowSamples;
/// How far the smoothed altitude must move before a climb or descent is banked.
final double elevationThresholdM;
/// Reproduces the constants the app shipped with before activity type existed —
/// `speedNoiseFloorKmh` in `telemetry.dart`, and the elevation window/threshold that
/// were the defaults in `stats/ride_statistics.dart`. Kept as literals here rather than
/// importing those constants, to avoid a dependency cycle between the domain layer and
/// the layers built on top of it. If those numbers ever change, this must change with
/// them, and vice versa — there is a parity-style test asserting the two stay equal.
static const motorcycle = ActivityProfile(
noiseFloorKmh: 1.5,
histogramBucketKmh: 10,
accuracyGateM: 50.0,
elevationWindowSamples: 15,
elevationThresholdM: 3.0,
);
/// Same shape as a motorcycle: comparable speeds, comparable GPS behaviour.
static const scooter = motorcycle;
/// Bicycles run slower than motorcycles, so the noise floor and histogram bucket both
/// need to be finer or real movement gets folded into "not moving".
static const bicycle = ActivityProfile(
noiseFloorKmh: 1.0,
histogramBucketKmh: 5,
accuracyGateM: 50.0,
elevationWindowSamples: 15,
elevationThresholdM: 3.0,
);
/// Similar speed profile to a bicycle; tighter accuracy gate since skateboarding tends
/// to happen close to buildings, where multipath makes bad fixes more common.
static const skateboard = ActivityProfile(
noiseFloorKmh: 1.0,
histogramBucketKmh: 5,
accuracyGateM: 30.0,
elevationWindowSamples: 15,
elevationThresholdM: 3.0,
);
/// Real movement lives much closer to GPS noise than a vehicle ever does, so the floor
/// drops hard and the histogram wants ~1 km/h bands rather than 10. The wider elevation
/// window compensates for covering less ground per sample at running pace.
static const running = ActivityProfile(
noiseFloorKmh: 0.5,
histogramBucketKmh: 1,
accuracyGateM: 30.0,
elevationWindowSamples: 25,
elevationThresholdM: 3.0,
);
/// Slower again than running.
static const walking = ActivityProfile(
noiseFloorKmh: 0.3,
histogramBucketKmh: 1,
accuracyGateM: 30.0,
elevationWindowSamples: 25,
elevationThresholdM: 3.0,
);
/// The safest generic default: the most permissive on accuracy and least likely to
/// silently drop real movement, of everything above.
static const other = motorcycle;
factory ActivityProfile.forActivity(Activity activity) => switch (activity) {
Activity.motorcycle => motorcycle,
Activity.bicycle => bicycle,
Activity.scooter => scooter,
Activity.skateboard => skateboard,
Activity.running => running,
Activity.walking => walking,
Activity.other => other,
};
}

View File

@@ -24,6 +24,15 @@ library;
/// OS restarts it mid-ride. Hence both.
enum TripState { recording, paused, completed }
/// What a ride was done on.
///
/// The recording pipeline never cared — it records positions, speeds and altitudes
/// regardless of what produced them. This exists so the app can stop assuming
/// motorcycle, and so activity can drive per-activity defaults (see
/// `domain/activity_profile.dart`) rather than just labelling the row. See V3-01 in
/// `docs/v3/`.
enum Activity { motorcycle, bicycle, scooter, skateboard, running, walking, other }
/// One ride, from pressing Start to pressing Stop.
///
/// The aggregate fields are denormalised on purpose. They are accumulated as points
@@ -36,6 +45,7 @@ class Trip {
this.endedAt,
this.name,
this.state = TripState.recording,
this.activity = Activity.motorcycle,
this.distanceM = 0.0,
this.movingMillis = 0,
this.maxSpeedKmh = 0.0,
@@ -53,6 +63,9 @@ class Trip {
final String? name;
final TripState state;
/// Defaults to motorcycle, matching every trip recorded before this field existed.
final Activity activity;
final double distanceM;
final int movingMillis;
final double maxSpeedKmh;
@@ -69,6 +82,7 @@ class Trip {
int? endedAt,
String? name,
TripState? state,
Activity? activity,
double? distanceM,
int? movingMillis,
double? maxSpeedKmh,
@@ -80,6 +94,7 @@ class Trip {
endedAt: endedAt ?? this.endedAt,
name: name ?? this.name,
state: state ?? this.state,
activity: activity ?? this.activity,
distanceM: distanceM ?? this.distanceM,
movingMillis: movingMillis ?? this.movingMillis,
maxSpeedKmh: maxSpeedKmh ?? this.maxSpeedKmh,