diff --git a/docs/v3/V3-01-activity-type.md b/docs/v3/V3-01-activity-type.md index c80133c..600904a 100644 --- a/docs/v3/V3-01-activity-type.md +++ b/docs/v3/V3-01-activity-type.md @@ -1,6 +1,6 @@ # V3-01 — Activity type per ride -**Phase** Foundations · **Depends on** nothing · **Size** M · **Status** Not started +**Phase** Foundations · **Depends on** nothing · **Size** M · **Status** Done ## Goal Every ride records what it was done on — motorcycle, bicycle, scooter, skateboard, @@ -67,3 +67,31 @@ Default to the last activity used; make it editable on trip detail next to renam ## Out of scope Per-activity totals or a stats screen. GPX `` export (see V3-14). + + +## Outcome + +Shipped as designed, with two deliberate deviations from the ticket text, both +recorded here rather than silently: + +- **No `Config.lastActivity` field.** "Default to the last activity used" is instead + derived live from the trips table itself (`AppDatabase.mostRecentTrip()` / + `TripRepository._lastUsedActivity()`) rather than duplicated into a separate + preference. One source of truth, no write path to keep in sync, and it degrades + correctly to `motorcycle` when the database is empty. +- **`ActivityProfile` lives in `domain/activity_profile.dart`**, not folded into + `models.dart` — kept the domain model (`Trip.activity`) separate from the behavioural + defaults built on top of it, and avoided a dependency cycle between the domain layer + and `stats/`/`recording/`. + +Map-fit zoom (mentioned in the ticket's defaults table) turned out to need no work: +`RideMap` already fits to the ride's actual recorded bounds, which is activity-agnostic +by construction. + +The widget-test pass caught a real overflow bug independent of activity type: the +7-item activity picker sheet overflowed a `Column`-based `showModalBottomSheet` the same +way the record screen once did (see `docs/port/PROGRESS.md`, Phase 4). Fixed with a +scrollable `ListView` + `isScrollControlled: true`, the same shape as that earlier fix. + +188 tests total (171 → 188): migration (2), `ActivityProfile` (5), `computeSummary` +profile-threading proof (3), repository (5), widget (2). diff --git a/lib/src/data/database.dart b/lib/src/data/database.dart index 8da75e2..9234c05 100644 --- a/lib/src/data/database.dart +++ b/lib/src/data/database.dart @@ -45,6 +45,11 @@ class Trips extends Table { TextColumn get state => textEnum()(); + /// Defaults to motorcycle so existing rows survive the migration that adds this + /// column unchanged — see [MigrationStrategy.onUpgrade] below. + TextColumn get activity => + textEnum().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 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 mostRecentTrip() async { + final row = + await (select(trips) + ..orderBy([(t) => OrderingTerm.desc(t.id)]) + ..limit(1)) + .getSingleOrNull(); + return row == null ? null : _toTrip(row); + } + Future 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, diff --git a/lib/src/data/database.g.dart b/lib/src/data/database.g.dart index c7e8ee7..292f8f0 100644 --- a/lib/src/data/database.g.dart +++ b/lib/src/data/database.g.dart @@ -61,6 +61,16 @@ class $TripsTable extends Trips with TableInfo<$TripsTable, TripRow> { type: DriftSqlType.string, requiredDuringInsert: true, ).withConverter($TripsTable.$converterstate); + @override + late final GeneratedColumnWithTypeConverter + activity = GeneratedColumn( + 'activity', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('motorcycle'), + ).withConverter($TripsTable.$converteractivity); static const VerificationMeta _distanceMMeta = const VerificationMeta( 'distanceM', ); @@ -128,6 +138,7 @@ class $TripsTable extends Trips with TableInfo<$TripsTable, TripRow> { endedAt, name, state, + activity, distanceM, movingMillis, maxSpeedKmh, @@ -239,6 +250,12 @@ class $TripsTable extends Trips with TableInfo<$TripsTable, TripRow> { data['${effectivePrefix}state'], )!, ), + activity: $TripsTable.$converteractivity.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}activity'], + )!, + ), distanceM: attachedDatabase.typeMapping.read( DriftSqlType.double, data['${effectivePrefix}distanceM'], @@ -269,6 +286,10 @@ class $TripsTable extends Trips with TableInfo<$TripsTable, TripRow> { static JsonTypeConverter2 $converterstate = const EnumNameConverter(domain.TripState.values); + static JsonTypeConverter2 + $converteractivity = const EnumNameConverter( + domain.Activity.values, + ); } class TripRow extends DataClass implements Insertable { @@ -282,6 +303,10 @@ class TripRow extends DataClass implements Insertable { /// Null means the UI derives a label from [startedAt]. Never store an empty string. final String? name; final domain.TripState state; + + /// Defaults to motorcycle so existing rows survive the migration that adds this + /// column unchanged — see [MigrationStrategy.onUpgrade] below. + final domain.Activity activity; final double distanceM; final int movingMillis; final double maxSpeedKmh; @@ -293,6 +318,7 @@ class TripRow extends DataClass implements Insertable { this.endedAt, this.name, required this.state, + required this.activity, required this.distanceM, required this.movingMillis, required this.maxSpeedKmh, @@ -313,6 +339,11 @@ class TripRow extends DataClass implements Insertable { { map['state'] = Variable($TripsTable.$converterstate.toSql(state)); } + { + map['activity'] = Variable( + $TripsTable.$converteractivity.toSql(activity), + ); + } map['distanceM'] = Variable(distanceM); map['movingMillis'] = Variable(movingMillis); map['maxSpeedKmh'] = Variable(maxSpeedKmh); @@ -330,6 +361,7 @@ class TripRow extends DataClass implements Insertable { : Value(endedAt), name: name == null && nullToAbsent ? const Value.absent() : Value(name), state: Value(state), + activity: Value(activity), distanceM: Value(distanceM), movingMillis: Value(movingMillis), maxSpeedKmh: Value(maxSpeedKmh), @@ -351,6 +383,9 @@ class TripRow extends DataClass implements Insertable { state: $TripsTable.$converterstate.fromJson( serializer.fromJson(json['state']), ), + activity: $TripsTable.$converteractivity.fromJson( + serializer.fromJson(json['activity']), + ), distanceM: serializer.fromJson(json['distanceM']), movingMillis: serializer.fromJson(json['movingMillis']), maxSpeedKmh: serializer.fromJson(json['maxSpeedKmh']), @@ -369,6 +404,9 @@ class TripRow extends DataClass implements Insertable { 'state': serializer.toJson( $TripsTable.$converterstate.toJson(state), ), + 'activity': serializer.toJson( + $TripsTable.$converteractivity.toJson(activity), + ), 'distanceM': serializer.toJson(distanceM), 'movingMillis': serializer.toJson(movingMillis), 'maxSpeedKmh': serializer.toJson(maxSpeedKmh), @@ -383,6 +421,7 @@ class TripRow extends DataClass implements Insertable { Value endedAt = const Value.absent(), Value name = const Value.absent(), domain.TripState? state, + domain.Activity? activity, double? distanceM, int? movingMillis, double? maxSpeedKmh, @@ -394,6 +433,7 @@ class TripRow extends DataClass implements Insertable { endedAt: endedAt.present ? endedAt.value : this.endedAt, name: name.present ? name.value : this.name, state: state ?? this.state, + activity: activity ?? this.activity, distanceM: distanceM ?? this.distanceM, movingMillis: movingMillis ?? this.movingMillis, maxSpeedKmh: maxSpeedKmh ?? this.maxSpeedKmh, @@ -407,6 +447,7 @@ class TripRow extends DataClass implements Insertable { endedAt: data.endedAt.present ? data.endedAt.value : this.endedAt, name: data.name.present ? data.name.value : this.name, state: data.state.present ? data.state.value : this.state, + activity: data.activity.present ? data.activity.value : this.activity, distanceM: data.distanceM.present ? data.distanceM.value : this.distanceM, movingMillis: data.movingMillis.present ? data.movingMillis.value @@ -431,6 +472,7 @@ class TripRow extends DataClass implements Insertable { ..write('endedAt: $endedAt, ') ..write('name: $name, ') ..write('state: $state, ') + ..write('activity: $activity, ') ..write('distanceM: $distanceM, ') ..write('movingMillis: $movingMillis, ') ..write('maxSpeedKmh: $maxSpeedKmh, ') @@ -447,6 +489,7 @@ class TripRow extends DataClass implements Insertable { endedAt, name, state, + activity, distanceM, movingMillis, maxSpeedKmh, @@ -462,6 +505,7 @@ class TripRow extends DataClass implements Insertable { other.endedAt == this.endedAt && other.name == this.name && other.state == this.state && + other.activity == this.activity && other.distanceM == this.distanceM && other.movingMillis == this.movingMillis && other.maxSpeedKmh == this.maxSpeedKmh && @@ -475,6 +519,7 @@ class TripsCompanion extends UpdateCompanion { final Value endedAt; final Value name; final Value state; + final Value activity; final Value distanceM; final Value movingMillis; final Value maxSpeedKmh; @@ -486,6 +531,7 @@ class TripsCompanion extends UpdateCompanion { this.endedAt = const Value.absent(), this.name = const Value.absent(), this.state = const Value.absent(), + this.activity = const Value.absent(), this.distanceM = const Value.absent(), this.movingMillis = const Value.absent(), this.maxSpeedKmh = const Value.absent(), @@ -498,6 +544,7 @@ class TripsCompanion extends UpdateCompanion { this.endedAt = const Value.absent(), this.name = const Value.absent(), required domain.TripState state, + this.activity = const Value.absent(), this.distanceM = const Value.absent(), this.movingMillis = const Value.absent(), this.maxSpeedKmh = const Value.absent(), @@ -511,6 +558,7 @@ class TripsCompanion extends UpdateCompanion { Expression? endedAt, Expression? name, Expression? state, + Expression? activity, Expression? distanceM, Expression? movingMillis, Expression? maxSpeedKmh, @@ -523,6 +571,7 @@ class TripsCompanion extends UpdateCompanion { if (endedAt != null) 'endedAt': endedAt, if (name != null) 'name': name, if (state != null) 'state': state, + if (activity != null) 'activity': activity, if (distanceM != null) 'distanceM': distanceM, if (movingMillis != null) 'movingMillis': movingMillis, if (maxSpeedKmh != null) 'maxSpeedKmh': maxSpeedKmh, @@ -537,6 +586,7 @@ class TripsCompanion extends UpdateCompanion { Value? endedAt, Value? name, Value? state, + Value? activity, Value? distanceM, Value? movingMillis, Value? maxSpeedKmh, @@ -549,6 +599,7 @@ class TripsCompanion extends UpdateCompanion { 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, @@ -577,6 +628,11 @@ class TripsCompanion extends UpdateCompanion { $TripsTable.$converterstate.toSql(state.value), ); } + if (activity.present) { + map['activity'] = Variable( + $TripsTable.$converteractivity.toSql(activity.value), + ); + } if (distanceM.present) { map['distanceM'] = Variable(distanceM.value); } @@ -603,6 +659,7 @@ class TripsCompanion extends UpdateCompanion { ..write('endedAt: $endedAt, ') ..write('name: $name, ') ..write('state: $state, ') + ..write('activity: $activity, ') ..write('distanceM: $distanceM, ') ..write('movingMillis: $movingMillis, ') ..write('maxSpeedKmh: $maxSpeedKmh, ') @@ -1638,6 +1695,7 @@ typedef $$TripsTableCreateCompanionBuilder = TripsCompanion Function({ Value endedAt, Value name, required domain.TripState state, + Value activity, Value distanceM, Value movingMillis, Value maxSpeedKmh, @@ -1650,6 +1708,7 @@ typedef $$TripsTableUpdateCompanionBuilder = TripsCompanion Function({ Value endedAt, Value name, Value state, + Value activity, Value distanceM, Value movingMillis, Value maxSpeedKmh, @@ -1732,6 +1791,12 @@ class $$TripsTableFilterComposer extends Composer<_$AppDatabase, $TripsTable> { builder: (column) => ColumnWithTypeConverterFilters(column), ); + ColumnWithTypeConverterFilters + get activity => $composableBuilder( + column: $table.activity, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + ColumnFilters get distanceM => $composableBuilder( column: $table.distanceM, builder: (column) => ColumnFilters(column), @@ -1842,6 +1907,11 @@ class $$TripsTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get activity => $composableBuilder( + column: $table.activity, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get distanceM => $composableBuilder( column: $table.distanceM, builder: (column) => ColumnOrderings(column), @@ -1892,6 +1962,9 @@ class $$TripsTableAnnotationComposer GeneratedColumnWithTypeConverter get state => $composableBuilder(column: $table.state, builder: (column) => column); + GeneratedColumnWithTypeConverter get activity => + $composableBuilder(column: $table.activity, builder: (column) => column); + GeneratedColumn get distanceM => $composableBuilder(column: $table.distanceM, builder: (column) => column); @@ -1999,6 +2072,7 @@ class $$TripsTableTableManager Value endedAt = const Value.absent(), Value name = const Value.absent(), Value state = const Value.absent(), + Value activity = const Value.absent(), Value distanceM = const Value.absent(), Value movingMillis = const Value.absent(), Value maxSpeedKmh = const Value.absent(), @@ -2010,6 +2084,7 @@ class $$TripsTableTableManager endedAt: endedAt, name: name, state: state, + activity: activity, distanceM: distanceM, movingMillis: movingMillis, maxSpeedKmh: maxSpeedKmh, @@ -2023,6 +2098,7 @@ class $$TripsTableTableManager Value endedAt = const Value.absent(), Value name = const Value.absent(), required domain.TripState state, + Value activity = const Value.absent(), Value distanceM = const Value.absent(), Value movingMillis = const Value.absent(), Value maxSpeedKmh = const Value.absent(), @@ -2034,6 +2110,7 @@ class $$TripsTableTableManager endedAt: endedAt, name: name, state: state, + activity: activity, distanceM: distanceM, movingMillis: movingMillis, maxSpeedKmh: maxSpeedKmh, diff --git a/lib/src/data/trip_repository.dart b/lib/src/data/trip_repository.dart index 4d64eb2..c7dcc17 100644 --- a/lib/src/data/trip_repository.dart +++ b/lib/src/data/trip_repository.dart @@ -13,6 +13,7 @@ /// must be a no-op rather than opening a duplicate segment. library; +import '../domain/activity_profile.dart'; import '../domain/models.dart'; import '../stats/ride_statistics.dart'; import 'database.dart'; @@ -75,17 +76,33 @@ class TripRepository { /// /// Adoption rather than rejection is deliberate: after a process kill the trip row /// still exists, and the restarted recorder needs to continue it, not start a second. - Future startTrip(int now) => _db.transaction(() async { - final existing = await _db.getActiveTrip(); - if (existing != null) { - return _adoptOrOpenSegment(existing, now); - } - final tripId = await _db.insertTrip( - Trip(startedAt: now, state: TripState.recording), - ); - final segmentId = await _db.insertSegment(tripId, now); - return TripHandle(tripId, segmentId); - }); + /// + /// [activity] defaults to whatever was last used ([_lastUsedActivity]) rather than + /// requiring a picker in front of Start — the founding premise is press-and-go with + /// gloves on. Pass it explicitly only where something else has already chosen it + /// (there is nothing that does yet). + Future startTrip(int now, {Activity? activity}) => + _db.transaction(() async { + final existing = await _db.getActiveTrip(); + if (existing != null) { + return _adoptOrOpenSegment(existing, now); + } + final resolvedActivity = activity ?? await _lastUsedActivity(); + final tripId = await _db.insertTrip( + Trip( + startedAt: now, + state: TripState.recording, + activity: resolvedActivity, + ), + ); + final segmentId = await _db.insertSegment(tripId, now); + return TripHandle(tripId, segmentId); + }); + + Future _lastUsedActivity() async { + final row = await _db.mostRecentTrip(); + return row?.activity ?? Activity.motorcycle; + } /// Closes the open segment and marks the trip paused. The trip itself stays open — /// only [completeTrip] sets `endedAt`. @@ -180,6 +197,13 @@ class TripRepository { Future deleteTrip(int id) => _db.deleteTrip(id); + /// Changes a trip's activity and recomputes its aggregates under the new profile — a + /// noise floor or elevation window tuned for a motorcycle may not fit a walk. + Future setActivity(int tripId, Activity activity) async { + await _db.setTripActivity(tripId, activity); + await recomputeAggregates(tripId); + } + /// Combines two completed rides into one. /// /// The earlier trip survives, the later one's segments and points are re-parented onto @@ -228,11 +252,17 @@ class TripRepository { /// /// This is the authoritative pass. The recorder's live accumulation is an estimate, so /// running this on completion means a mid-ride process kill cannot leave permanently - /// skewed totals. + /// skewed totals. Reads the trip's own activity to pick the right profile, so this + /// stays correct after [setActivity] as well as after a normal completion. Future recomputeAggregates(int tripId) async { + final trip = await _db.getTrip(tripId); + final profile = ActivityProfile.forActivity( + trip?.activity ?? Activity.motorcycle, + ); final summary = computeSummary( await _db.pointsForTrip(tripId), segments: await _db.segmentsForTrip(tripId), + profile: profile, ); await _db.updateAggregates( id: tripId, diff --git a/lib/src/domain/activity_profile.dart b/lib/src/domain/activity_profile.dart new file mode 100644 index 0000000..0586f88 --- /dev/null +++ b/lib/src/domain/activity_profile.dart @@ -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, + }; +} diff --git a/lib/src/domain/models.dart b/lib/src/domain/models.dart index 328b6bd..065f9c4 100644 --- a/lib/src/domain/models.dart +++ b/lib/src/domain/models.dart @@ -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, diff --git a/lib/src/recording/recording_engine.dart b/lib/src/recording/recording_engine.dart index fcfab97..fa203a8 100644 --- a/lib/src/recording/recording_engine.dart +++ b/lib/src/recording/recording_engine.dart @@ -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? _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, diff --git a/lib/src/recording/ride_accumulator.dart b/lib/src/recording/ride_accumulator.dart index 7969b7f..f7b22fd 100644 --- a/lib/src/recording/ride_accumulator.dart +++ b/lib/src/recording/ride_accumulator.dart @@ -16,12 +16,24 @@ library; import 'dart:math' as math; +import '../domain/activity_profile.dart'; import '../domain/models.dart'; import '../geo/geo.dart'; import '../stats/ride_statistics.dart'; -import '../telemetry/telemetry.dart'; class Accumulator { + Accumulator({this.profile = ActivityProfile.motorcycle}) + : _elevation = ElevationAccumulator( + windowSize: profile.elevationWindowSamples, + thresholdM: profile.elevationThresholdM, + ); + + /// Which activity's defaults this accumulator is folding under. Fixed for the life of + /// the instance — a trip whose activity changes gets a fresh `Accumulator`, not a + /// mutated one, so live and re-seeded state never disagree about which profile made + /// them. + final ActivityProfile profile; + /// The last point of the *previous* batch. /// /// Without this, distance restarts at every flush boundary and silently under-reports @@ -29,7 +41,7 @@ class Accumulator { /// until they compare against an odometer. TrackPoint? _lastPoint; - ElevationAccumulator _elevation = ElevationAccumulator(); + ElevationAccumulator _elevation; double _restoredElevationM = 0.0; double _distanceM = 0.0; @@ -61,7 +73,7 @@ class Accumulator { final dt = point.timestamp - prev.timestamp; if (dt >= 1 && dt <= maxSampleGapMillis && - point.speedKmh >= speedNoiseFloorKmh) { + point.speedKmh >= profile.noiseFloorKmh) { _movingMillis += dt; } } @@ -93,13 +105,19 @@ class Accumulator { // Elevation cannot be resumed mid-run from a scalar total, so the accumulator // restarts and only gain from here on is added to the persisted figure. The // authoritative recomputation at trip completion corrects any drift. - _elevation = ElevationAccumulator(); + _elevation = ElevationAccumulator( + windowSize: profile.elevationWindowSamples, + thresholdM: profile.elevationThresholdM, + ); _restoredElevationM = elevationGainM; } void reset() { _lastPoint = null; - _elevation = ElevationAccumulator(); + _elevation = ElevationAccumulator( + windowSize: profile.elevationWindowSamples, + thresholdM: profile.elevationThresholdM, + ); _distanceM = 0.0; _movingMillis = 0; _maxSpeedKmh = 0.0; diff --git a/lib/src/stats/ride_statistics.dart b/lib/src/stats/ride_statistics.dart index 76861bc..a62c1ec 100644 --- a/lib/src/stats/ride_statistics.dart +++ b/lib/src/stats/ride_statistics.dart @@ -11,9 +11,9 @@ library; import 'dart:collection'; import 'dart:math' as math; +import '../domain/activity_profile.dart'; import '../domain/models.dart'; import '../geo/geo.dart'; -import '../telemetry/telemetry.dart'; class RideSummary { const RideSummary({ @@ -128,6 +128,7 @@ Map> _groupBySegment(List points) { RideSummary computeSummary( List points, { List segments = const [], + ActivityProfile profile = ActivityProfile.motorcycle, }) { if (points.isEmpty) return RideSummary.empty; @@ -135,7 +136,10 @@ RideSummary computeSummary( var movingMillis = 0; var maxSpeedKmh = 0.0; - final elevation = ElevationAccumulator(); + final elevation = ElevationAccumulator( + windowSize: profile.elevationWindowSamples, + thresholdM: profile.elevationThresholdM, + ); // Grouping by segment is what keeps a pause from inventing distance: points either // side of a gas-station stop can be kilometres apart. @@ -155,7 +159,7 @@ RideSummary computeSummary( final dt = point.timestamp - previous.timestamp; if (dt >= 1 && dt <= maxSampleGapMillis && - point.speedKmh >= speedNoiseFloorKmh) { + point.speedKmh >= profile.noiseFloorKmh) { movingMillis += dt; } } diff --git a/lib/src/telemetry/telemetry.dart b/lib/src/telemetry/telemetry.dart index 5092eaa..53cf6db 100644 --- a/lib/src/telemetry/telemetry.dart +++ b/lib/src/telemetry/telemetry.dart @@ -17,8 +17,11 @@ double msToKmh(double metersPerSecond) => metersPerSecond * msToKmhFactor; /// "max speed" is not set by GPS noise while the phone sits in a pocket. const double speedNoiseFloorKmh = 1.5; -double sanitizeSpeedKmh(double raw) { - if (!raw.isFinite || raw < speedNoiseFloorKmh) return 0.0; +/// [floorKmh] defaults to the motorcycle noise floor; callers with an activity in hand +/// should pass `ActivityProfile.forActivity(activity).noiseFloorKmh` instead — a walk's +/// real movement lives much closer to GPS jitter than a motorcycle's does. +double sanitizeSpeedKmh(double raw, {double floorKmh = speedNoiseFloorKmh}) { + if (!raw.isFinite || raw < floorKmh) return 0.0; return raw; } diff --git a/lib/src/ui/activity_display.dart b/lib/src/ui/activity_display.dart new file mode 100644 index 0000000..5872ca3 --- /dev/null +++ b/lib/src/ui/activity_display.dart @@ -0,0 +1,29 @@ +/// Icon and label for each [Activity], shared between the trips list and trip detail. +/// +/// Kept Flutter-dependent and separate from `ui/format.dart`, which stays pure Dart with +/// no widget imports. +library; + +import 'package:flutter/material.dart'; + +import '../domain/models.dart'; + +String activityLabel(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', +}; + +IconData activityIcon(Activity activity) => switch (activity) { + Activity.motorcycle => Icons.two_wheeler, + Activity.bicycle => Icons.pedal_bike, + Activity.scooter => Icons.electric_scooter, + Activity.skateboard => Icons.skateboarding, + Activity.running => Icons.directions_run, + Activity.walking => Icons.directions_walk, + Activity.other => Icons.route, +}; diff --git a/lib/src/ui/detail/trip_detail_screen.dart b/lib/src/ui/detail/trip_detail_screen.dart index 0d81b5e..1628683 100644 --- a/lib/src/ui/detail/trip_detail_screen.dart +++ b/lib/src/ui/detail/trip_detail_screen.dart @@ -8,10 +8,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../app/providers.dart'; -import '../../export/export_manager.dart'; +import '../../domain/activity_profile.dart'; import '../../domain/models.dart'; +import '../../export/export_manager.dart'; import '../../stats/ride_statistics.dart'; import '../../telemetry/telemetry.dart'; +import '../activity_display.dart'; import '../components/ride_map.dart'; import '../components/stats.dart'; import '../format.dart'; @@ -50,13 +52,17 @@ final tripDetailProvider = FutureProvider.autoDispose.family(( final points = await repo.pointsForTrip(tripId); final segments = await repo.segmentsForTrip(tripId); + final activityProfile = ActivityProfile.forActivity(trip.activity); return TripDetail( trip: trip, - summary: computeSummary(points, segments: segments), + summary: computeSummary(points, segments: segments, profile: activityProfile), points: points, segments: segments, - histogram: speedHistogram(points), + histogram: speedHistogram( + points, + bucketKmh: activityProfile.histogramBucketKmh, + ), profile: elevationProfile(points), mapEnabled: ref.watch(mapEnabledProvider), ); @@ -101,7 +107,10 @@ class TripDetailScreen extends ConsumerWidget { message: 'This ride no longer exists.', icon: Icons.help_outline, ), - AsyncValue(hasValue: true, value: final d?) => _Body(detail: d), + AsyncValue(hasValue: true, value: final d?) => _Body( + detail: d, + onEditActivity: () => _editActivity(context, ref, d.trip), + ), AsyncValue(hasError: true, :final error) => EmptyState( message: 'Could not load this ride.\n$error', ), @@ -174,12 +183,46 @@ class TripDetailScreen extends ConsumerWidget { await ref.read(tripRepositoryProvider).renameTrip(trip.id, name); ref.invalidate(tripDetailProvider(trip.id)); } + + /// No picker in front of Start — V3-01's constraint — but a ride's activity is fully + /// editable here after the fact, the same pattern as rename. + Future _editActivity(BuildContext context, WidgetRef ref, Trip trip) async { + final chosen = await showModalBottomSheet( + context: context, + // Seven options, unlike the two-item export sheet -- a plain Column with + // mainAxisSize.min overflowed on a short screen. Scrollable rather than fixed. + isScrollControlled: true, + builder: (context) => SafeArea( + child: ListView( + shrinkWrap: true, + children: [ + for (final activity in Activity.values) + ListTile( + key: Key('activity-${activity.name}'), + leading: Icon(activityIcon(activity)), + title: Text(activityLabel(activity)), + trailing: activity == trip.activity + ? const Icon(Icons.check) + : null, + onTap: () => Navigator.of(context).pop(activity), + ), + ], + ), + ), + ); + if (chosen == null || chosen == trip.activity) return; + // Recomputes aggregates under the new profile -- a noise floor tuned for a + // motorcycle should not silently keep governing a walk. + await ref.read(tripRepositoryProvider).setActivity(trip.id, chosen); + ref.invalidate(tripDetailProvider(trip.id)); + } } class _Body extends StatelessWidget { - const _Body({required this.detail}); + const _Body({required this.detail, required this.onEditActivity}); final TripDetail detail; + final VoidCallback onEditActivity; @override Widget build(BuildContext context) { @@ -195,6 +238,13 @@ class _Body extends StatelessWidget { unit: 'km', ), ), + const SizedBox(height: 16), + Center( + child: _ActivityRow( + activity: detail.trip.activity, + onTap: onEditActivity, + ), + ), const SizedBox(height: 24), if (detail.mapEnabled) RideMap(points: detail.points, segments: detail.segments), @@ -246,6 +296,37 @@ class _Body extends StatelessWidget { } } +/// A tappable "what this ride was" row — icon, label, and a small edit affordance. +class _ActivityRow extends StatelessWidget { + const _ActivityRow({required this.activity, required this.onTap}); + + final Activity activity; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return InkWell( + key: const Key('activity-row'), + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(activityIcon(activity), size: 16, color: colors.outline), + const SizedBox(width: 6), + Text(activityLabel(activity), style: TextStyle(color: colors.outline)), + const SizedBox(width: 4), + Icon(Icons.edit_outlined, size: 13, color: colors.outline), + ], + ), + ), + ); + } +} + class _SectionTitle extends StatelessWidget { const _SectionTitle(this.text); diff --git a/lib/src/ui/trips/trips_screen.dart b/lib/src/ui/trips/trips_screen.dart index 20db349..7bf949f 100644 --- a/lib/src/ui/trips/trips_screen.dart +++ b/lib/src/ui/trips/trips_screen.dart @@ -10,6 +10,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../app/providers.dart'; import '../../domain/models.dart'; import '../../telemetry/telemetry.dart'; +import '../activity_display.dart'; import '../components/stats.dart'; import '../format.dart'; @@ -226,7 +227,7 @@ class _TripTile extends StatelessWidget { selected ? Icons.check_circle : Icons.circle_outlined, color: selected ? colors.primary : colors.outline, ) - : null, + : Icon(activityIcon(trip.activity), color: colors.outline), title: Text( tripLabel(trip), style: const TextStyle(fontWeight: FontWeight.w600), diff --git a/pubspec.lock b/pubspec.lock index e05d8c9..cdac83e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -973,7 +973,7 @@ packages: source: hosted version: "0.7.0+eol" sqlite3: - dependency: transitive + dependency: "direct dev" description: name: sqlite3 sha256: "64b2c63c8232dd20d14b34105a81ebfd74320442e8451f836179ec89986aa478" diff --git a/pubspec.yaml b/pubspec.yaml index c480fcb..effb8f7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -65,6 +65,7 @@ dev_dependencies: build_runner: ^2.16.0 mocktail: ^1.0.5 xml: ^6.6.1 + sqlite3: ^3.5.1 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/test/activity_profile_test.dart b/test/activity_profile_test.dart new file mode 100644 index 0000000..f17650e --- /dev/null +++ b/test/activity_profile_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:rippr/src/domain/activity_profile.dart'; +import 'package:rippr/src/domain/models.dart'; +import 'package:rippr/src/telemetry/telemetry.dart'; + +/// Ported concept check for V3-01: activity type must drive real behaviour, not just a +/// label. +void main() { + test( + 'the motorcycle profile reproduces the constants the app shipped with before ' + 'activity type existed', + () { + // ActivityProfile.motorcycle deliberately duplicates these as literals rather than + // importing them, to avoid a domain-layer dependency cycle -- this is the promised + // check that the two never drift apart silently. + expect(ActivityProfile.motorcycle.noiseFloorKmh, speedNoiseFloorKmh); + expect(ActivityProfile.motorcycle.elevationThresholdM, 3.0); + expect(ActivityProfile.motorcycle.elevationWindowSamples, 15); + expect(ActivityProfile.motorcycle.accuracyGateM, 50.0); + expect(ActivityProfile.motorcycle.histogramBucketKmh, 10); + }, + ); + + test('forActivity covers every Activity value with no fallback surprises', () { + for (final activity in Activity.values) { + // Must not throw -- an unhandled case here would be a runtime crash on a + // brand-new trip, not a compile error, since the switch is exhaustive over the + // enum today but a future Activity value could slip through review. + expect(() => ActivityProfile.forActivity(activity), returnsNormally); + } + }); + + test('slower activities get a lower noise floor than a motorcycle', () { + final motorcycle = ActivityProfile.forActivity(Activity.motorcycle); + final walking = ActivityProfile.forActivity(Activity.walking); + final running = ActivityProfile.forActivity(Activity.running); + + expect(walking.noiseFloorKmh, lessThan(motorcycle.noiseFloorKmh)); + expect(running.noiseFloorKmh, lessThan(motorcycle.noiseFloorKmh)); + expect( + walking.noiseFloorKmh, + lessThan(running.noiseFloorKmh), + reason: 'a walk is slower than a run, so real movement sits even closer to noise', + ); + }); + + test('running and walking use a finer speed histogram than a motorcycle', () { + // 10 km/h bands are useless for an activity that rarely exceeds 10 km/h at all. + expect(ActivityProfile.forActivity(Activity.running).histogramBucketKmh, 1); + expect(ActivityProfile.forActivity(Activity.walking).histogramBucketKmh, 1); + expect(ActivityProfile.forActivity(Activity.motorcycle).histogramBucketKmh, 10); + }); + + test('scooter and other fall back to the safest generic profile', () { + expect( + ActivityProfile.forActivity(Activity.scooter), + same(ActivityProfile.motorcycle), + ); + expect( + ActivityProfile.forActivity(Activity.other), + same(ActivityProfile.motorcycle), + ); + }); +} diff --git a/test/migration_test.dart b/test/migration_test.dart new file mode 100644 index 0000000..0a5e562 --- /dev/null +++ b/test/migration_test.dart @@ -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); + }); +} diff --git a/test/ride_statistics_test.dart b/test/ride_statistics_test.dart index 3fba41e..d798327 100644 --- a/test/ride_statistics_test.dart +++ b/test/ride_statistics_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:rippr/src/domain/activity_profile.dart'; import 'package:rippr/src/domain/models.dart'; import 'package:rippr/src/stats/ride_statistics.dart'; @@ -299,4 +300,57 @@ void main() { expect(elevationProfile([point(ts: 0)]).length, 1); }); }); + + group('activity profile changes real output (V3-01)', () { + test('a slow walk reads as stationary under the motorcycle floor, but not under ' + "walking's own", () { + // 20 fixes a second apart, each ~0.7 km/h -- real walking pace, comfortably under + // the motorcycle noise floor (1.5) but above the walking one (0.3). This is the + // whole point of activity profiles: without one, a recorded walk would show + // 00:00:00 moving time despite every fix showing genuine movement. + final points = List.generate( + 20, + (i) => point(ts: i * 1000, lat: 51.0 + i * 0.0000065, speed: 0.7), + ); + + final asMotorcycle = + computeSummary(points, profile: ActivityProfile.motorcycle); + final asWalking = computeSummary(points, profile: ActivityProfile.walking); + + expect(asMotorcycle.movingMillis, 0, + reason: 'the motorcycle floor must reject real walking speed as noise'); + expect(asWalking.movingMillis, greaterThan(0), + reason: "the walking profile must recognise its own pace as movement"); + }); + + test('elevation window size changes what counts as a real climb', () { + // A short, shallow rise across only a handful of samples. A wider averaging + // window (running/walking) smooths it away as noise; the narrower motorcycle + // window banks more of it as real. + final points = List.generate( + 10, + (i) => point(ts: i * 1000, alt: 1000.0 + i * 0.5), + ); + + final motorcycle = + computeSummary(points, profile: ActivityProfile.motorcycle).elevationGainM; + final running = + computeSummary(points, profile: ActivityProfile.running).elevationGainM; + + expect(running, lessThanOrEqualTo(motorcycle), + reason: "running's wider smoothing window must not report MORE gain than " + "motorcycle's narrower one on the same climb"); + }); + + test('defaults to the motorcycle profile when none is given', () { + final points = List.generate( + 5, + (i) => point(ts: i * 1000, speed: 40.0), + ); + expect( + computeSummary(points), + computeSummary(points, profile: ActivityProfile.motorcycle), + ); + }); + }); } diff --git a/test/trip_repository_test.dart b/test/trip_repository_test.dart index 727c69f..525834e 100644 --- a/test/trip_repository_test.dart +++ b/test/trip_repository_test.dart @@ -3,6 +3,7 @@ import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:rippr/src/data/database.dart'; import 'package:rippr/src/data/trip_repository.dart'; +import 'package:rippr/src/domain/activity_profile.dart'; import 'package:rippr/src/domain/models.dart'; /// Ported from `com.rippr.data.TripRepositoryTest` and `MergeTest`. @@ -187,6 +188,84 @@ void main() { }); }); + // --------------------------------------------------------------------------- + // Activity type — V3-01 + // --------------------------------------------------------------------------- + + group('activity', () { + test('a brand new database defaults a fresh trip to motorcycle', () async { + final h = await repo.startTrip(1000); + expect((await repo.tripById(h.tripId))!.activity, Activity.motorcycle); + }); + + test('starting a new trip defaults to whatever activity was last used, ' + 'with no picker required', () async { + final first = await repo.startTrip(1000); + await repo.completeTrip(2000); + await repo.setActivity(first.tripId, Activity.walking); + + final second = await repo.startTrip(3000); + + expect((await repo.tripById(second.tripId))!.activity, Activity.walking, + reason: 'the new ride should inherit the most recently created trip\'s ' + 'activity, not silently reset to motorcycle'); + }); + + test('an explicit activity overrides the last-used default', () async { + final h = await repo.startTrip(1000, activity: Activity.bicycle); + expect((await repo.tripById(h.tripId))!.activity, Activity.bicycle); + }); + + test('resuming an active trip never changes its activity', () async { + final h = await repo.startTrip(1000, activity: Activity.running); + await repo.pauseTrip(2000); + // Started again with no explicit activity -- must adopt the paused trip's own, + // not fall back to whatever "last used" would otherwise resolve to. + final resumed = await repo.startTrip(3000); + + expect(resumed.tripId, h.tripId); + expect((await repo.tripById(h.tripId))!.activity, Activity.running); + }); + + test('setActivity recomputes aggregates under the new profile', () async { + // A slow walking pace: under the motorcycle noise floor (1.5 km/h) but above + // walking's own (0.3), so moving time genuinely differs between the two profiles. + final h = await repo.startTrip(1000, activity: Activity.motorcycle); + for (var i = 0; i < 10; i++) { + await addPoint(h, 1000 + i * 1000, speed: 0.7); + } + await repo.completeTrip(11000); + // completeTrip() alone does not recompute aggregates -- the recorder does that + // separately on stop(). Mirror that here rather than relying on a leftover value. + await repo.recomputeAggregates(h.tripId); + + final asMotorcycle = (await repo.tripById(h.tripId))!; + expect(asMotorcycle.movingMillis, 0, + reason: 'a motorcycle profile must read a 0.7 km/h pace as noise'); + + await repo.setActivity(h.tripId, Activity.walking); + + final asWalking = (await repo.tripById(h.tripId))!; + expect(asWalking.activity, Activity.walking); + expect(asWalking.movingMillis, greaterThan(0), + reason: 'recomputing under the walking profile must recognise the same ' + 'fixes as real movement'); + // Sanity check that this really did come from ActivityProfile.walking and not a + // coincidence -- computing with the profile explicitly must agree. + expect( + asWalking.movingMillis, + greaterThan(asMotorcycle.movingMillis), + ); + // The bicycle/motorcycle-equivalent profile does not exist as a category here; + // just confirm the profile object itself expresses the difference this test + // exercises indirectly through the repository. + expect( + ActivityProfile.walking.noiseFloorKmh, + lessThan(ActivityProfile.motorcycle.noiseFloorKmh), + ); + }); + }); + group('process death', () { test('a fresh repository over the same database sees the active trip', () async { diff --git a/test/widget_test.dart b/test/widget_test.dart index 5c532b2..815bc13 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -7,6 +7,7 @@ import 'package:rippr/src/app/providers.dart'; import 'package:rippr/src/data/database.dart'; import 'package:rippr/src/data/trip_repository.dart'; import 'package:rippr/src/domain/models.dart'; +import 'package:rippr/src/ui/activity_display.dart'; import 'package:rippr/src/recording/location_source.dart'; import 'package:rippr/src/ui/detail/trip_detail_screen.dart'; import 'package:rippr/src/ui/record/record_screen.dart'; @@ -282,6 +283,27 @@ void main() { expect(await repo.tripById(a), isNull); }); + + screenTest('a tile shows its own activity icon (V3-01)', (tester) async { + final motorcycleTrip = + await seedCompletedTrip(startedAt: 1000, endedAt: 5000, name: 'Ride'); + final bikeTrip = + await seedCompletedTrip(startedAt: 10000, endedAt: 15000, name: 'Bike'); + await repo.setActivity(bikeTrip, Activity.bicycle); + + await tester.pumpWidget(host(const TripsScreen())); + await tester.pumpAndSettle(); + + Icon iconFor(int tripId) => tester.widget( + find.descendant( + of: find.byKey(Key('trip-$tripId')), + matching: find.byType(Icon), + ), + ); + + expect(iconFor(motorcycleTrip).icon, activityIcon(Activity.motorcycle)); + expect(iconFor(bikeTrip).icon, activityIcon(Activity.bicycle)); + }); }); group('trip detail', () { @@ -340,5 +362,23 @@ void main() { expect(find.text('Coast run'), findsOneWidget); expect((await repo.tripById(id))!.name, 'Coast run'); }); + + screenTest('editing the activity updates the row and recomputes aggregates', + (tester) async { + final id = await seedCompletedTrip(startedAt: 1000, endedAt: 5000); + + await tester.pumpWidget(host(TripDetailScreen(tripId: id), map: false)); + await tester.pumpAndSettle(); + + expect(find.text(activityLabel(Activity.motorcycle)), findsOneWidget); + + await tester.tap(find.byKey(const Key('activity-row'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(Key('activity-${Activity.walking.name}'))); + await tester.pumpAndSettle(); + + expect(find.text(activityLabel(Activity.walking)), findsOneWidget); + expect((await repo.tripById(id))!.activity, Activity.walking); + }); }); }