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

@@ -45,6 +45,11 @@ class Trips extends Table {
TextColumn get state => textEnum<domain.TripState>()();
/// Defaults to motorcycle so existing rows survive the migration that adds this
/// column unchanged — see [MigrationStrategy.onUpgrade] below.
TextColumn get activity =>
textEnum<domain.Activity>().withDefault(const Constant('motorcycle'))();
RealColumn get distanceM => real().withDefault(const Constant(0))();
IntColumn get movingMillis => integer().withDefault(const Constant(0))();
RealColumn get maxSpeedKmh => real().withDefault(const Constant(0))();
@@ -101,11 +106,18 @@ class AppDatabase extends _$AppDatabase {
AppDatabase(super.e);
@override
int get schemaVersion => 1;
int get schemaVersion => 2;
@override
MigrationStrategy get migration => MigrationStrategy(
onCreate: (m) => m.createAll(),
// The port's first real migration. The destructive fallback the native app removed
// in v2 stays removed here too — see the file doc comment above.
onUpgrade: (m, from, to) async {
if (from < 2) {
await m.addColumn(trips, trips.activity);
}
},
beforeOpen: (details) async {
// Non-negotiable: without this the CASCADE relationships above do nothing.
await customStatement('PRAGMA foreign_keys = ON');
@@ -165,6 +177,7 @@ class AppDatabase extends _$AppDatabase {
endedAt: Value(trip.endedAt),
name: Value(trip.name),
state: trip.state,
activity: Value(trip.activity),
distanceM: Value(trip.distanceM),
movingMillis: Value(trip.movingMillis),
maxSpeedKmh: Value(trip.maxSpeedKmh),
@@ -173,6 +186,23 @@ class AppDatabase extends _$AppDatabase {
),
);
Future<void> setTripActivity(int id, domain.Activity activity) => (update(
trips,
)..where((t) => t.id.equals(id))).write(
TripsCompanion(activity: Value(activity)),
);
/// The most recently created trip, regardless of state — used to default a new ride's
/// activity to whatever was last used, so there is no picker in front of Start.
Future<domain.Trip?> mostRecentTrip() async {
final row =
await (select(trips)
..orderBy([(t) => OrderingTerm.desc(t.id)])
..limit(1))
.getSingleOrNull();
return row == null ? null : _toTrip(row);
}
Future<void> renameTrip(int id, String? name) => (update(
trips,
)..where((t) => t.id.equals(id))).write(TripsCompanion(name: Value(name)));
@@ -420,6 +450,7 @@ domain.Trip _toTrip(TripRow r) => domain.Trip(
endedAt: r.endedAt,
name: r.name,
state: r.state,
activity: r.activity,
distanceM: r.distanceM,
movingMillis: r.movingMillis,
maxSpeedKmh: r.maxSpeedKmh,