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,

View File

@@ -61,6 +61,16 @@ class $TripsTable extends Trips with TableInfo<$TripsTable, TripRow> {
type: DriftSqlType.string,
requiredDuringInsert: true,
).withConverter<domain.TripState>($TripsTable.$converterstate);
@override
late final GeneratedColumnWithTypeConverter<domain.Activity, String>
activity = GeneratedColumn<String>(
'activity',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: false,
defaultValue: const Constant('motorcycle'),
).withConverter<domain.Activity>($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<domain.TripState, String, String> $converterstate =
const EnumNameConverter<domain.TripState>(domain.TripState.values);
static JsonTypeConverter2<domain.Activity, String, String>
$converteractivity = const EnumNameConverter<domain.Activity>(
domain.Activity.values,
);
}
class TripRow extends DataClass implements Insertable<TripRow> {
@@ -282,6 +303,10 @@ class TripRow extends DataClass implements Insertable<TripRow> {
/// 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<TripRow> {
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<TripRow> {
{
map['state'] = Variable<String>($TripsTable.$converterstate.toSql(state));
}
{
map['activity'] = Variable<String>(
$TripsTable.$converteractivity.toSql(activity),
);
}
map['distanceM'] = Variable<double>(distanceM);
map['movingMillis'] = Variable<int>(movingMillis);
map['maxSpeedKmh'] = Variable<double>(maxSpeedKmh);
@@ -330,6 +361,7 @@ class TripRow extends DataClass implements Insertable<TripRow> {
: 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<TripRow> {
state: $TripsTable.$converterstate.fromJson(
serializer.fromJson<String>(json['state']),
),
activity: $TripsTable.$converteractivity.fromJson(
serializer.fromJson<String>(json['activity']),
),
distanceM: serializer.fromJson<double>(json['distanceM']),
movingMillis: serializer.fromJson<int>(json['movingMillis']),
maxSpeedKmh: serializer.fromJson<double>(json['maxSpeedKmh']),
@@ -369,6 +404,9 @@ class TripRow extends DataClass implements Insertable<TripRow> {
'state': serializer.toJson<String>(
$TripsTable.$converterstate.toJson(state),
),
'activity': serializer.toJson<String>(
$TripsTable.$converteractivity.toJson(activity),
),
'distanceM': serializer.toJson<double>(distanceM),
'movingMillis': serializer.toJson<int>(movingMillis),
'maxSpeedKmh': serializer.toJson<double>(maxSpeedKmh),
@@ -383,6 +421,7 @@ class TripRow extends DataClass implements Insertable<TripRow> {
Value<int?> endedAt = const Value.absent(),
Value<String?> 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<TripRow> {
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<TripRow> {
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<TripRow> {
..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<TripRow> {
endedAt,
name,
state,
activity,
distanceM,
movingMillis,
maxSpeedKmh,
@@ -462,6 +505,7 @@ class TripRow extends DataClass implements Insertable<TripRow> {
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<TripRow> {
final Value<int?> endedAt;
final Value<String?> name;
final Value<domain.TripState> state;
final Value<domain.Activity> activity;
final Value<double> distanceM;
final Value<int> movingMillis;
final Value<double> maxSpeedKmh;
@@ -486,6 +531,7 @@ class TripsCompanion extends UpdateCompanion<TripRow> {
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<TripRow> {
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<TripRow> {
Expression<int>? endedAt,
Expression<String>? name,
Expression<String>? state,
Expression<String>? activity,
Expression<double>? distanceM,
Expression<int>? movingMillis,
Expression<double>? maxSpeedKmh,
@@ -523,6 +571,7 @@ class TripsCompanion extends UpdateCompanion<TripRow> {
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<TripRow> {
Value<int?>? endedAt,
Value<String?>? name,
Value<domain.TripState>? state,
Value<domain.Activity>? activity,
Value<double>? distanceM,
Value<int>? movingMillis,
Value<double>? maxSpeedKmh,
@@ -549,6 +599,7 @@ class TripsCompanion extends UpdateCompanion<TripRow> {
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<TripRow> {
$TripsTable.$converterstate.toSql(state.value),
);
}
if (activity.present) {
map['activity'] = Variable<String>(
$TripsTable.$converteractivity.toSql(activity.value),
);
}
if (distanceM.present) {
map['distanceM'] = Variable<double>(distanceM.value);
}
@@ -603,6 +659,7 @@ class TripsCompanion extends UpdateCompanion<TripRow> {
..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<int?> endedAt,
Value<String?> name,
required domain.TripState state,
Value<domain.Activity> activity,
Value<double> distanceM,
Value<int> movingMillis,
Value<double> maxSpeedKmh,
@@ -1650,6 +1708,7 @@ typedef $$TripsTableUpdateCompanionBuilder = TripsCompanion Function({
Value<int?> endedAt,
Value<String?> name,
Value<domain.TripState> state,
Value<domain.Activity> activity,
Value<double> distanceM,
Value<int> movingMillis,
Value<double> maxSpeedKmh,
@@ -1732,6 +1791,12 @@ class $$TripsTableFilterComposer extends Composer<_$AppDatabase, $TripsTable> {
builder: (column) => ColumnWithTypeConverterFilters(column),
);
ColumnWithTypeConverterFilters<domain.Activity, domain.Activity, String>
get activity => $composableBuilder(
column: $table.activity,
builder: (column) => ColumnWithTypeConverterFilters(column),
);
ColumnFilters<double> get distanceM => $composableBuilder(
column: $table.distanceM,
builder: (column) => ColumnFilters(column),
@@ -1842,6 +1907,11 @@ class $$TripsTableOrderingComposer
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get activity => $composableBuilder(
column: $table.activity,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<double> get distanceM => $composableBuilder(
column: $table.distanceM,
builder: (column) => ColumnOrderings(column),
@@ -1892,6 +1962,9 @@ class $$TripsTableAnnotationComposer
GeneratedColumnWithTypeConverter<domain.TripState, String> get state =>
$composableBuilder(column: $table.state, builder: (column) => column);
GeneratedColumnWithTypeConverter<domain.Activity, String> get activity =>
$composableBuilder(column: $table.activity, builder: (column) => column);
GeneratedColumn<double> get distanceM =>
$composableBuilder(column: $table.distanceM, builder: (column) => column);
@@ -1999,6 +2072,7 @@ class $$TripsTableTableManager
Value<int?> endedAt = const Value.absent(),
Value<String?> name = const Value.absent(),
Value<domain.TripState> state = const Value.absent(),
Value<domain.Activity> activity = const Value.absent(),
Value<double> distanceM = const Value.absent(),
Value<int> movingMillis = const Value.absent(),
Value<double> 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<int?> endedAt = const Value.absent(),
Value<String?> name = const Value.absent(),
required domain.TripState state,
Value<domain.Activity> activity = const Value.absent(),
Value<double> distanceM = const Value.absent(),
Value<int> movingMillis = const Value.absent(),
Value<double> maxSpeedKmh = const Value.absent(),
@@ -2034,6 +2110,7 @@ class $$TripsTableTableManager
endedAt: endedAt,
name: name,
state: state,
activity: activity,
distanceM: distanceM,
movingMillis: movingMillis,
maxSpeedKmh: maxSpeedKmh,

View File

@@ -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<TripHandle> 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<TripHandle> 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<Activity> _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<void> 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<void> 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<void> 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,

View File

@@ -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,
};
}

View File

@@ -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,

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,

View File

@@ -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;

View File

@@ -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<int, List<TrackPoint>> _groupBySegment(List<TrackPoint> points) {
RideSummary computeSummary(
List<TrackPoint> points, {
List<Segment> 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;
}
}

View File

@@ -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;
}

View File

@@ -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,
};

View File

@@ -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<TripDetail?, int>((
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<void> _editActivity(BuildContext context, WidgetRef ref, Trip trip) async {
final chosen = await showModalBottomSheet<Activity>(
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);

View File

@@ -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),