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

@@ -37,6 +37,7 @@ import 'dart:async';
import 'package:synchronized/synchronized.dart';
import '../data/trip_repository.dart';
import '../domain/activity_profile.dart';
import '../domain/models.dart';
import '../telemetry/live_telemetry.dart';
import '../telemetry/telemetry.dart';
@@ -93,7 +94,14 @@ class RecordingEngine {
StreamSubscription<LocationFix>? _subscription;
Timer? _flushTimer;
final Accumulator _accumulator = Accumulator();
/// Rebuilt, not mutated, whenever a new trip session begins — see [start] — so its
/// [Accumulator.profile] always matches the trip it is accumulating for.
Accumulator _accumulator = Accumulator();
/// Which activity's defaults are currently in force for the fix path — the accuracy
/// gate and noise floor in [_onFix] read this directly, since that callback must stay
/// synchronous and cannot query the trip's activity from the database per fix.
ActivityProfile _activeProfile = ActivityProfile.motorcycle;
// Read by the fix callback, written by the lifecycle path.
int _currentTripId = 0;
@@ -132,7 +140,15 @@ class RecordingEngine {
await _writeLock.synchronized(() async {
if (handle.tripId != _currentTripId) {
_accumulator.reset();
// A different trip session: rebuild the accumulator under that trip's own
// activity profile, rather than mutating the previous trip's accumulator in
// place — a ride switched from walking to a motorcycle mid-session must not
// keep walking's noise floor.
final trip = await _repo.tripById(handle.tripId);
_activeProfile = ActivityProfile.forActivity(
trip?.activity ?? Activity.motorcycle,
);
_accumulator = Accumulator(profile: _activeProfile);
await _seedAccumulator(handle.tripId);
}
// A new segment must not measure distance back to the pre-pause position.
@@ -233,8 +249,9 @@ class RecordingEngine {
// avoids.
final handle = await _repo.resumeIntoNewSegment(_now());
if (handle == null) break;
_activeProfile = ActivityProfile.forActivity(trip!.activity);
await _writeLock.synchronized(() async {
_accumulator.reset();
_accumulator = Accumulator(profile: _activeProfile);
await _seedAccumulator(handle.tripId);
_accumulator.onSegmentChanged();
});
@@ -246,6 +263,7 @@ class RecordingEngine {
_setState(RecorderState.recording);
case TripState.paused:
_currentTripId = trip!.id;
_activeProfile = ActivityProfile.forActivity(trip.activity);
_setState(RecorderState.paused);
case TripState.completed:
case null:
@@ -263,7 +281,9 @@ class RecordingEngine {
/// Called for every GPS fix. **Must not block and must not touch the database.**
void _onFix(LocationFix fix) {
if (!isUsableFix(fix.accuracyM)) return;
if (!isUsableFix(fix.accuracyM, maxAccuracyMeters: _activeProfile.accuracyGateM)) {
return;
}
// Stamped at creation, which is what makes a pause safe: a fix already in flight
// lands in the segment it was recorded during, not the next one.
@@ -277,7 +297,10 @@ class RecordingEngine {
timestamp: fix.timestamp,
latitude: fix.latitude,
longitude: fix.longitude,
speedKmh: sanitizeSpeedKmh(msToKmh(fix.speedMps)),
speedKmh: sanitizeSpeedKmh(
msToKmh(fix.speedMps),
floorKmh: _activeProfile.noiseFloorKmh,
),
altitudeM: fix.altitudeM,
accuracyM: fix.accuracyM,
bearingDeg: fix.bearingDeg,