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

150
test/migration_test.dart Normal file
View File

@@ -0,0 +1,150 @@
import 'dart:io';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:rippr/src/data/database.dart';
import 'package:rippr/src/domain/models.dart';
import 'package:sqlite3/sqlite3.dart' as sqlite3;
/// V3-01's migration test — the one that matters more than the feature it belongs to.
///
/// The destructive fallback is gone for good (see the doc comment on [AppDatabase]), so
/// this is the port's first real schema migration, and getting the path right here is
/// what makes every later one trustworthy.
///
/// Builds the v1 schema with raw SQL via `package:sqlite3` directly, rather than
/// drift_dev's schema-versioning tooling — deliberately: that tooling needs a
/// `build.yaml`-declared set of frozen schema snapshots this project has not adopted, and
/// a hand-built v1 table is a faithful enough stand-in. It is copied column-for-column
/// from `Trips`/`Segments`/`TrackPoints` as they were before the `activity` column was
/// added, using the same names — `build.yaml`'s `case_from_dart_to_sql: preserve` means
/// there is no snake_case translation to get wrong.
void main() {
late Directory tempDir;
late File dbFile;
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('rippr_migration_test_');
dbFile = File('${tempDir.path}/v1.sqlite');
});
tearDown(() async {
if (tempDir.existsSync()) tempDir.deleteSync(recursive: true);
});
/// Writes a database file containing exactly the v1 schema, seeded with one trip, one
/// closed segment, and a handful of points -- then closes it, so Drift can open the
/// same file fresh.
void seedV1Database() {
final raw = sqlite3.sqlite3.open(dbFile.path);
raw.execute('''
CREATE TABLE trips (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
startedAt INTEGER NOT NULL,
endedAt INTEGER NULL,
name TEXT NULL,
state TEXT NOT NULL,
distanceM REAL NOT NULL DEFAULT 0,
movingMillis INTEGER NOT NULL DEFAULT 0,
maxSpeedKmh REAL NOT NULL DEFAULT 0,
elevationGainM REAL NOT NULL DEFAULT 0,
pointCount INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_trips_ended ON trips (endedAt);
CREATE TABLE segments (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
tripId INTEGER NOT NULL REFERENCES trips (id) ON DELETE CASCADE,
startedAt INTEGER NOT NULL,
endedAt INTEGER NULL
);
CREATE INDEX idx_segments_trip ON segments (tripId);
CREATE TABLE track_points (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
tripId INTEGER NOT NULL REFERENCES trips (id) ON DELETE CASCADE,
segmentId INTEGER NOT NULL REFERENCES segments (id) ON DELETE CASCADE,
timestamp INTEGER NOT NULL,
latitude REAL NOT NULL,
longitude REAL NOT NULL,
speedKmh REAL NOT NULL,
altitudeM REAL NOT NULL,
accuracyM REAL NOT NULL DEFAULT 0,
bearingDeg REAL NOT NULL DEFAULT 0,
synced INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_points_trip ON track_points (tripId);
CREATE INDEX idx_points_segment ON track_points (segmentId);
CREATE INDEX idx_points_synced ON track_points (synced);
''');
raw.execute('''
INSERT INTO trips (id, startedAt, endedAt, name, state, distanceM, movingMillis,
maxSpeedKmh, elevationGainM, pointCount)
VALUES (1, 1000, 5000, 'Old ride', 'completed', 111.2, 4000, 40.0, 12.0, 3);
''');
raw.execute('''
INSERT INTO segments (id, tripId, startedAt, endedAt) VALUES (1, 1, 1000, 5000);
''');
for (var i = 0; i < 3; i++) {
raw.execute('''
INSERT INTO track_points
(tripId, segmentId, timestamp, latitude, longitude, speedKmh, altitudeM)
VALUES (1, 1, ${1000 + i * 1000}, ${51.0 + i * 0.0001}, -114.0, 40.0, 1000.0);
''');
}
// Drift persists its schema version in SQLite's own user_version pragma. Setting it
// to 1 here is what makes AppDatabase (schemaVersion 2) believe an upgrade is due
// when it opens this file, rather than treating it as a fresh database.
raw.execute('PRAGMA user_version = 1;');
raw.close();
}
test('a v1 database migrates and keeps every ride and point', () async {
seedV1Database();
final db = AppDatabase(NativeDatabase(dbFile));
addTearDown(db.close);
final trip = await db.getTrip(1);
expect(trip, isNotNull, reason: 'the pre-existing ride must survive the migration');
expect(trip!.name, 'Old ride');
expect(trip.distanceM, closeTo(111.2, 1e-9));
expect(trip.pointCount, 3);
expect(
trip.activity,
Activity.motorcycle,
reason: 'a ride recorded before activity existed must default to motorcycle, '
'not be left null or reject the row',
);
final points = await db.pointsForTrip(1);
expect(points.length, 3, reason: 'no point may be lost in the upgrade');
final segments = await db.segmentsForTrip(1);
expect(segments.length, 1);
// The column is genuinely usable afterwards, not just present with a default.
await db.setTripActivity(1, Activity.bicycle);
expect((await db.getTrip(1))!.activity, Activity.bicycle);
});
test('a v1 database with several rides migrates all of them', () async {
seedV1Database();
final raw = sqlite3.sqlite3.open(dbFile.path);
raw.execute('''
INSERT INTO trips (startedAt, endedAt, state, distanceM, pointCount)
VALUES (10000, 15000, 'completed', 500.0, 10),
(20000, 25000, 'completed', 1000.0, 20);
''');
raw.close();
final db = AppDatabase(NativeDatabase(dbFile));
addTearDown(db.close);
final all = await db.watchCompletedTrips().first;
expect(all.length, 3);
expect(all.every((t) => t.activity == Activity.motorcycle), isTrue);
});
}