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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user