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:
@@ -1,6 +1,6 @@
|
|||||||
# V3-01 — Activity type per ride
|
# 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
|
## Goal
|
||||||
Every ride records what it was done on — motorcycle, bicycle, scooter, skateboard,
|
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
|
## Out of scope
|
||||||
Per-activity totals or a stats screen. GPX `<type>` export (see V3-14).
|
Per-activity totals or a stats screen. GPX `<type>` 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).
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ class Trips extends Table {
|
|||||||
|
|
||||||
TextColumn get state => textEnum<domain.TripState>()();
|
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))();
|
RealColumn get distanceM => real().withDefault(const Constant(0))();
|
||||||
IntColumn get movingMillis => integer().withDefault(const Constant(0))();
|
IntColumn get movingMillis => integer().withDefault(const Constant(0))();
|
||||||
RealColumn get maxSpeedKmh => real().withDefault(const Constant(0))();
|
RealColumn get maxSpeedKmh => real().withDefault(const Constant(0))();
|
||||||
@@ -101,11 +106,18 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
AppDatabase(super.e);
|
AppDatabase(super.e);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 1;
|
int get schemaVersion => 2;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration => MigrationStrategy(
|
MigrationStrategy get migration => MigrationStrategy(
|
||||||
onCreate: (m) => m.createAll(),
|
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 {
|
beforeOpen: (details) async {
|
||||||
// Non-negotiable: without this the CASCADE relationships above do nothing.
|
// Non-negotiable: without this the CASCADE relationships above do nothing.
|
||||||
await customStatement('PRAGMA foreign_keys = ON');
|
await customStatement('PRAGMA foreign_keys = ON');
|
||||||
@@ -165,6 +177,7 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
endedAt: Value(trip.endedAt),
|
endedAt: Value(trip.endedAt),
|
||||||
name: Value(trip.name),
|
name: Value(trip.name),
|
||||||
state: trip.state,
|
state: trip.state,
|
||||||
|
activity: Value(trip.activity),
|
||||||
distanceM: Value(trip.distanceM),
|
distanceM: Value(trip.distanceM),
|
||||||
movingMillis: Value(trip.movingMillis),
|
movingMillis: Value(trip.movingMillis),
|
||||||
maxSpeedKmh: Value(trip.maxSpeedKmh),
|
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(
|
Future<void> renameTrip(int id, String? name) => (update(
|
||||||
trips,
|
trips,
|
||||||
)..where((t) => t.id.equals(id))).write(TripsCompanion(name: Value(name)));
|
)..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,
|
endedAt: r.endedAt,
|
||||||
name: r.name,
|
name: r.name,
|
||||||
state: r.state,
|
state: r.state,
|
||||||
|
activity: r.activity,
|
||||||
distanceM: r.distanceM,
|
distanceM: r.distanceM,
|
||||||
movingMillis: r.movingMillis,
|
movingMillis: r.movingMillis,
|
||||||
maxSpeedKmh: r.maxSpeedKmh,
|
maxSpeedKmh: r.maxSpeedKmh,
|
||||||
|
|||||||
@@ -61,6 +61,16 @@ class $TripsTable extends Trips with TableInfo<$TripsTable, TripRow> {
|
|||||||
type: DriftSqlType.string,
|
type: DriftSqlType.string,
|
||||||
requiredDuringInsert: true,
|
requiredDuringInsert: true,
|
||||||
).withConverter<domain.TripState>($TripsTable.$converterstate);
|
).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(
|
static const VerificationMeta _distanceMMeta = const VerificationMeta(
|
||||||
'distanceM',
|
'distanceM',
|
||||||
);
|
);
|
||||||
@@ -128,6 +138,7 @@ class $TripsTable extends Trips with TableInfo<$TripsTable, TripRow> {
|
|||||||
endedAt,
|
endedAt,
|
||||||
name,
|
name,
|
||||||
state,
|
state,
|
||||||
|
activity,
|
||||||
distanceM,
|
distanceM,
|
||||||
movingMillis,
|
movingMillis,
|
||||||
maxSpeedKmh,
|
maxSpeedKmh,
|
||||||
@@ -239,6 +250,12 @@ class $TripsTable extends Trips with TableInfo<$TripsTable, TripRow> {
|
|||||||
data['${effectivePrefix}state'],
|
data['${effectivePrefix}state'],
|
||||||
)!,
|
)!,
|
||||||
),
|
),
|
||||||
|
activity: $TripsTable.$converteractivity.fromSql(
|
||||||
|
attachedDatabase.typeMapping.read(
|
||||||
|
DriftSqlType.string,
|
||||||
|
data['${effectivePrefix}activity'],
|
||||||
|
)!,
|
||||||
|
),
|
||||||
distanceM: attachedDatabase.typeMapping.read(
|
distanceM: attachedDatabase.typeMapping.read(
|
||||||
DriftSqlType.double,
|
DriftSqlType.double,
|
||||||
data['${effectivePrefix}distanceM'],
|
data['${effectivePrefix}distanceM'],
|
||||||
@@ -269,6 +286,10 @@ class $TripsTable extends Trips with TableInfo<$TripsTable, TripRow> {
|
|||||||
|
|
||||||
static JsonTypeConverter2<domain.TripState, String, String> $converterstate =
|
static JsonTypeConverter2<domain.TripState, String, String> $converterstate =
|
||||||
const EnumNameConverter<domain.TripState>(domain.TripState.values);
|
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> {
|
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.
|
/// Null means the UI derives a label from [startedAt]. Never store an empty string.
|
||||||
final String? name;
|
final String? name;
|
||||||
final domain.TripState state;
|
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 double distanceM;
|
||||||
final int movingMillis;
|
final int movingMillis;
|
||||||
final double maxSpeedKmh;
|
final double maxSpeedKmh;
|
||||||
@@ -293,6 +318,7 @@ class TripRow extends DataClass implements Insertable<TripRow> {
|
|||||||
this.endedAt,
|
this.endedAt,
|
||||||
this.name,
|
this.name,
|
||||||
required this.state,
|
required this.state,
|
||||||
|
required this.activity,
|
||||||
required this.distanceM,
|
required this.distanceM,
|
||||||
required this.movingMillis,
|
required this.movingMillis,
|
||||||
required this.maxSpeedKmh,
|
required this.maxSpeedKmh,
|
||||||
@@ -313,6 +339,11 @@ class TripRow extends DataClass implements Insertable<TripRow> {
|
|||||||
{
|
{
|
||||||
map['state'] = Variable<String>($TripsTable.$converterstate.toSql(state));
|
map['state'] = Variable<String>($TripsTable.$converterstate.toSql(state));
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
map['activity'] = Variable<String>(
|
||||||
|
$TripsTable.$converteractivity.toSql(activity),
|
||||||
|
);
|
||||||
|
}
|
||||||
map['distanceM'] = Variable<double>(distanceM);
|
map['distanceM'] = Variable<double>(distanceM);
|
||||||
map['movingMillis'] = Variable<int>(movingMillis);
|
map['movingMillis'] = Variable<int>(movingMillis);
|
||||||
map['maxSpeedKmh'] = Variable<double>(maxSpeedKmh);
|
map['maxSpeedKmh'] = Variable<double>(maxSpeedKmh);
|
||||||
@@ -330,6 +361,7 @@ class TripRow extends DataClass implements Insertable<TripRow> {
|
|||||||
: Value(endedAt),
|
: Value(endedAt),
|
||||||
name: name == null && nullToAbsent ? const Value.absent() : Value(name),
|
name: name == null && nullToAbsent ? const Value.absent() : Value(name),
|
||||||
state: Value(state),
|
state: Value(state),
|
||||||
|
activity: Value(activity),
|
||||||
distanceM: Value(distanceM),
|
distanceM: Value(distanceM),
|
||||||
movingMillis: Value(movingMillis),
|
movingMillis: Value(movingMillis),
|
||||||
maxSpeedKmh: Value(maxSpeedKmh),
|
maxSpeedKmh: Value(maxSpeedKmh),
|
||||||
@@ -351,6 +383,9 @@ class TripRow extends DataClass implements Insertable<TripRow> {
|
|||||||
state: $TripsTable.$converterstate.fromJson(
|
state: $TripsTable.$converterstate.fromJson(
|
||||||
serializer.fromJson<String>(json['state']),
|
serializer.fromJson<String>(json['state']),
|
||||||
),
|
),
|
||||||
|
activity: $TripsTable.$converteractivity.fromJson(
|
||||||
|
serializer.fromJson<String>(json['activity']),
|
||||||
|
),
|
||||||
distanceM: serializer.fromJson<double>(json['distanceM']),
|
distanceM: serializer.fromJson<double>(json['distanceM']),
|
||||||
movingMillis: serializer.fromJson<int>(json['movingMillis']),
|
movingMillis: serializer.fromJson<int>(json['movingMillis']),
|
||||||
maxSpeedKmh: serializer.fromJson<double>(json['maxSpeedKmh']),
|
maxSpeedKmh: serializer.fromJson<double>(json['maxSpeedKmh']),
|
||||||
@@ -369,6 +404,9 @@ class TripRow extends DataClass implements Insertable<TripRow> {
|
|||||||
'state': serializer.toJson<String>(
|
'state': serializer.toJson<String>(
|
||||||
$TripsTable.$converterstate.toJson(state),
|
$TripsTable.$converterstate.toJson(state),
|
||||||
),
|
),
|
||||||
|
'activity': serializer.toJson<String>(
|
||||||
|
$TripsTable.$converteractivity.toJson(activity),
|
||||||
|
),
|
||||||
'distanceM': serializer.toJson<double>(distanceM),
|
'distanceM': serializer.toJson<double>(distanceM),
|
||||||
'movingMillis': serializer.toJson<int>(movingMillis),
|
'movingMillis': serializer.toJson<int>(movingMillis),
|
||||||
'maxSpeedKmh': serializer.toJson<double>(maxSpeedKmh),
|
'maxSpeedKmh': serializer.toJson<double>(maxSpeedKmh),
|
||||||
@@ -383,6 +421,7 @@ class TripRow extends DataClass implements Insertable<TripRow> {
|
|||||||
Value<int?> endedAt = const Value.absent(),
|
Value<int?> endedAt = const Value.absent(),
|
||||||
Value<String?> name = const Value.absent(),
|
Value<String?> name = const Value.absent(),
|
||||||
domain.TripState? state,
|
domain.TripState? state,
|
||||||
|
domain.Activity? activity,
|
||||||
double? distanceM,
|
double? distanceM,
|
||||||
int? movingMillis,
|
int? movingMillis,
|
||||||
double? maxSpeedKmh,
|
double? maxSpeedKmh,
|
||||||
@@ -394,6 +433,7 @@ class TripRow extends DataClass implements Insertable<TripRow> {
|
|||||||
endedAt: endedAt.present ? endedAt.value : this.endedAt,
|
endedAt: endedAt.present ? endedAt.value : this.endedAt,
|
||||||
name: name.present ? name.value : this.name,
|
name: name.present ? name.value : this.name,
|
||||||
state: state ?? this.state,
|
state: state ?? this.state,
|
||||||
|
activity: activity ?? this.activity,
|
||||||
distanceM: distanceM ?? this.distanceM,
|
distanceM: distanceM ?? this.distanceM,
|
||||||
movingMillis: movingMillis ?? this.movingMillis,
|
movingMillis: movingMillis ?? this.movingMillis,
|
||||||
maxSpeedKmh: maxSpeedKmh ?? this.maxSpeedKmh,
|
maxSpeedKmh: maxSpeedKmh ?? this.maxSpeedKmh,
|
||||||
@@ -407,6 +447,7 @@ class TripRow extends DataClass implements Insertable<TripRow> {
|
|||||||
endedAt: data.endedAt.present ? data.endedAt.value : this.endedAt,
|
endedAt: data.endedAt.present ? data.endedAt.value : this.endedAt,
|
||||||
name: data.name.present ? data.name.value : this.name,
|
name: data.name.present ? data.name.value : this.name,
|
||||||
state: data.state.present ? data.state.value : this.state,
|
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,
|
distanceM: data.distanceM.present ? data.distanceM.value : this.distanceM,
|
||||||
movingMillis: data.movingMillis.present
|
movingMillis: data.movingMillis.present
|
||||||
? data.movingMillis.value
|
? data.movingMillis.value
|
||||||
@@ -431,6 +472,7 @@ class TripRow extends DataClass implements Insertable<TripRow> {
|
|||||||
..write('endedAt: $endedAt, ')
|
..write('endedAt: $endedAt, ')
|
||||||
..write('name: $name, ')
|
..write('name: $name, ')
|
||||||
..write('state: $state, ')
|
..write('state: $state, ')
|
||||||
|
..write('activity: $activity, ')
|
||||||
..write('distanceM: $distanceM, ')
|
..write('distanceM: $distanceM, ')
|
||||||
..write('movingMillis: $movingMillis, ')
|
..write('movingMillis: $movingMillis, ')
|
||||||
..write('maxSpeedKmh: $maxSpeedKmh, ')
|
..write('maxSpeedKmh: $maxSpeedKmh, ')
|
||||||
@@ -447,6 +489,7 @@ class TripRow extends DataClass implements Insertable<TripRow> {
|
|||||||
endedAt,
|
endedAt,
|
||||||
name,
|
name,
|
||||||
state,
|
state,
|
||||||
|
activity,
|
||||||
distanceM,
|
distanceM,
|
||||||
movingMillis,
|
movingMillis,
|
||||||
maxSpeedKmh,
|
maxSpeedKmh,
|
||||||
@@ -462,6 +505,7 @@ class TripRow extends DataClass implements Insertable<TripRow> {
|
|||||||
other.endedAt == this.endedAt &&
|
other.endedAt == this.endedAt &&
|
||||||
other.name == this.name &&
|
other.name == this.name &&
|
||||||
other.state == this.state &&
|
other.state == this.state &&
|
||||||
|
other.activity == this.activity &&
|
||||||
other.distanceM == this.distanceM &&
|
other.distanceM == this.distanceM &&
|
||||||
other.movingMillis == this.movingMillis &&
|
other.movingMillis == this.movingMillis &&
|
||||||
other.maxSpeedKmh == this.maxSpeedKmh &&
|
other.maxSpeedKmh == this.maxSpeedKmh &&
|
||||||
@@ -475,6 +519,7 @@ class TripsCompanion extends UpdateCompanion<TripRow> {
|
|||||||
final Value<int?> endedAt;
|
final Value<int?> endedAt;
|
||||||
final Value<String?> name;
|
final Value<String?> name;
|
||||||
final Value<domain.TripState> state;
|
final Value<domain.TripState> state;
|
||||||
|
final Value<domain.Activity> activity;
|
||||||
final Value<double> distanceM;
|
final Value<double> distanceM;
|
||||||
final Value<int> movingMillis;
|
final Value<int> movingMillis;
|
||||||
final Value<double> maxSpeedKmh;
|
final Value<double> maxSpeedKmh;
|
||||||
@@ -486,6 +531,7 @@ class TripsCompanion extends UpdateCompanion<TripRow> {
|
|||||||
this.endedAt = const Value.absent(),
|
this.endedAt = const Value.absent(),
|
||||||
this.name = const Value.absent(),
|
this.name = const Value.absent(),
|
||||||
this.state = const Value.absent(),
|
this.state = const Value.absent(),
|
||||||
|
this.activity = const Value.absent(),
|
||||||
this.distanceM = const Value.absent(),
|
this.distanceM = const Value.absent(),
|
||||||
this.movingMillis = const Value.absent(),
|
this.movingMillis = const Value.absent(),
|
||||||
this.maxSpeedKmh = const Value.absent(),
|
this.maxSpeedKmh = const Value.absent(),
|
||||||
@@ -498,6 +544,7 @@ class TripsCompanion extends UpdateCompanion<TripRow> {
|
|||||||
this.endedAt = const Value.absent(),
|
this.endedAt = const Value.absent(),
|
||||||
this.name = const Value.absent(),
|
this.name = const Value.absent(),
|
||||||
required domain.TripState state,
|
required domain.TripState state,
|
||||||
|
this.activity = const Value.absent(),
|
||||||
this.distanceM = const Value.absent(),
|
this.distanceM = const Value.absent(),
|
||||||
this.movingMillis = const Value.absent(),
|
this.movingMillis = const Value.absent(),
|
||||||
this.maxSpeedKmh = const Value.absent(),
|
this.maxSpeedKmh = const Value.absent(),
|
||||||
@@ -511,6 +558,7 @@ class TripsCompanion extends UpdateCompanion<TripRow> {
|
|||||||
Expression<int>? endedAt,
|
Expression<int>? endedAt,
|
||||||
Expression<String>? name,
|
Expression<String>? name,
|
||||||
Expression<String>? state,
|
Expression<String>? state,
|
||||||
|
Expression<String>? activity,
|
||||||
Expression<double>? distanceM,
|
Expression<double>? distanceM,
|
||||||
Expression<int>? movingMillis,
|
Expression<int>? movingMillis,
|
||||||
Expression<double>? maxSpeedKmh,
|
Expression<double>? maxSpeedKmh,
|
||||||
@@ -523,6 +571,7 @@ class TripsCompanion extends UpdateCompanion<TripRow> {
|
|||||||
if (endedAt != null) 'endedAt': endedAt,
|
if (endedAt != null) 'endedAt': endedAt,
|
||||||
if (name != null) 'name': name,
|
if (name != null) 'name': name,
|
||||||
if (state != null) 'state': state,
|
if (state != null) 'state': state,
|
||||||
|
if (activity != null) 'activity': activity,
|
||||||
if (distanceM != null) 'distanceM': distanceM,
|
if (distanceM != null) 'distanceM': distanceM,
|
||||||
if (movingMillis != null) 'movingMillis': movingMillis,
|
if (movingMillis != null) 'movingMillis': movingMillis,
|
||||||
if (maxSpeedKmh != null) 'maxSpeedKmh': maxSpeedKmh,
|
if (maxSpeedKmh != null) 'maxSpeedKmh': maxSpeedKmh,
|
||||||
@@ -537,6 +586,7 @@ class TripsCompanion extends UpdateCompanion<TripRow> {
|
|||||||
Value<int?>? endedAt,
|
Value<int?>? endedAt,
|
||||||
Value<String?>? name,
|
Value<String?>? name,
|
||||||
Value<domain.TripState>? state,
|
Value<domain.TripState>? state,
|
||||||
|
Value<domain.Activity>? activity,
|
||||||
Value<double>? distanceM,
|
Value<double>? distanceM,
|
||||||
Value<int>? movingMillis,
|
Value<int>? movingMillis,
|
||||||
Value<double>? maxSpeedKmh,
|
Value<double>? maxSpeedKmh,
|
||||||
@@ -549,6 +599,7 @@ class TripsCompanion extends UpdateCompanion<TripRow> {
|
|||||||
endedAt: endedAt ?? this.endedAt,
|
endedAt: endedAt ?? this.endedAt,
|
||||||
name: name ?? this.name,
|
name: name ?? this.name,
|
||||||
state: state ?? this.state,
|
state: state ?? this.state,
|
||||||
|
activity: activity ?? this.activity,
|
||||||
distanceM: distanceM ?? this.distanceM,
|
distanceM: distanceM ?? this.distanceM,
|
||||||
movingMillis: movingMillis ?? this.movingMillis,
|
movingMillis: movingMillis ?? this.movingMillis,
|
||||||
maxSpeedKmh: maxSpeedKmh ?? this.maxSpeedKmh,
|
maxSpeedKmh: maxSpeedKmh ?? this.maxSpeedKmh,
|
||||||
@@ -577,6 +628,11 @@ class TripsCompanion extends UpdateCompanion<TripRow> {
|
|||||||
$TripsTable.$converterstate.toSql(state.value),
|
$TripsTable.$converterstate.toSql(state.value),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (activity.present) {
|
||||||
|
map['activity'] = Variable<String>(
|
||||||
|
$TripsTable.$converteractivity.toSql(activity.value),
|
||||||
|
);
|
||||||
|
}
|
||||||
if (distanceM.present) {
|
if (distanceM.present) {
|
||||||
map['distanceM'] = Variable<double>(distanceM.value);
|
map['distanceM'] = Variable<double>(distanceM.value);
|
||||||
}
|
}
|
||||||
@@ -603,6 +659,7 @@ class TripsCompanion extends UpdateCompanion<TripRow> {
|
|||||||
..write('endedAt: $endedAt, ')
|
..write('endedAt: $endedAt, ')
|
||||||
..write('name: $name, ')
|
..write('name: $name, ')
|
||||||
..write('state: $state, ')
|
..write('state: $state, ')
|
||||||
|
..write('activity: $activity, ')
|
||||||
..write('distanceM: $distanceM, ')
|
..write('distanceM: $distanceM, ')
|
||||||
..write('movingMillis: $movingMillis, ')
|
..write('movingMillis: $movingMillis, ')
|
||||||
..write('maxSpeedKmh: $maxSpeedKmh, ')
|
..write('maxSpeedKmh: $maxSpeedKmh, ')
|
||||||
@@ -1638,6 +1695,7 @@ typedef $$TripsTableCreateCompanionBuilder = TripsCompanion Function({
|
|||||||
Value<int?> endedAt,
|
Value<int?> endedAt,
|
||||||
Value<String?> name,
|
Value<String?> name,
|
||||||
required domain.TripState state,
|
required domain.TripState state,
|
||||||
|
Value<domain.Activity> activity,
|
||||||
Value<double> distanceM,
|
Value<double> distanceM,
|
||||||
Value<int> movingMillis,
|
Value<int> movingMillis,
|
||||||
Value<double> maxSpeedKmh,
|
Value<double> maxSpeedKmh,
|
||||||
@@ -1650,6 +1708,7 @@ typedef $$TripsTableUpdateCompanionBuilder = TripsCompanion Function({
|
|||||||
Value<int?> endedAt,
|
Value<int?> endedAt,
|
||||||
Value<String?> name,
|
Value<String?> name,
|
||||||
Value<domain.TripState> state,
|
Value<domain.TripState> state,
|
||||||
|
Value<domain.Activity> activity,
|
||||||
Value<double> distanceM,
|
Value<double> distanceM,
|
||||||
Value<int> movingMillis,
|
Value<int> movingMillis,
|
||||||
Value<double> maxSpeedKmh,
|
Value<double> maxSpeedKmh,
|
||||||
@@ -1732,6 +1791,12 @@ class $$TripsTableFilterComposer extends Composer<_$AppDatabase, $TripsTable> {
|
|||||||
builder: (column) => ColumnWithTypeConverterFilters(column),
|
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(
|
ColumnFilters<double> get distanceM => $composableBuilder(
|
||||||
column: $table.distanceM,
|
column: $table.distanceM,
|
||||||
builder: (column) => ColumnFilters(column),
|
builder: (column) => ColumnFilters(column),
|
||||||
@@ -1842,6 +1907,11 @@ class $$TripsTableOrderingComposer
|
|||||||
builder: (column) => ColumnOrderings(column),
|
builder: (column) => ColumnOrderings(column),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ColumnOrderings<String> get activity => $composableBuilder(
|
||||||
|
column: $table.activity,
|
||||||
|
builder: (column) => ColumnOrderings(column),
|
||||||
|
);
|
||||||
|
|
||||||
ColumnOrderings<double> get distanceM => $composableBuilder(
|
ColumnOrderings<double> get distanceM => $composableBuilder(
|
||||||
column: $table.distanceM,
|
column: $table.distanceM,
|
||||||
builder: (column) => ColumnOrderings(column),
|
builder: (column) => ColumnOrderings(column),
|
||||||
@@ -1892,6 +1962,9 @@ class $$TripsTableAnnotationComposer
|
|||||||
GeneratedColumnWithTypeConverter<domain.TripState, String> get state =>
|
GeneratedColumnWithTypeConverter<domain.TripState, String> get state =>
|
||||||
$composableBuilder(column: $table.state, builder: (column) => column);
|
$composableBuilder(column: $table.state, builder: (column) => column);
|
||||||
|
|
||||||
|
GeneratedColumnWithTypeConverter<domain.Activity, String> get activity =>
|
||||||
|
$composableBuilder(column: $table.activity, builder: (column) => column);
|
||||||
|
|
||||||
GeneratedColumn<double> get distanceM =>
|
GeneratedColumn<double> get distanceM =>
|
||||||
$composableBuilder(column: $table.distanceM, builder: (column) => column);
|
$composableBuilder(column: $table.distanceM, builder: (column) => column);
|
||||||
|
|
||||||
@@ -1999,6 +2072,7 @@ class $$TripsTableTableManager
|
|||||||
Value<int?> endedAt = const Value.absent(),
|
Value<int?> endedAt = const Value.absent(),
|
||||||
Value<String?> name = const Value.absent(),
|
Value<String?> name = const Value.absent(),
|
||||||
Value<domain.TripState> state = const Value.absent(),
|
Value<domain.TripState> state = const Value.absent(),
|
||||||
|
Value<domain.Activity> activity = const Value.absent(),
|
||||||
Value<double> distanceM = const Value.absent(),
|
Value<double> distanceM = const Value.absent(),
|
||||||
Value<int> movingMillis = const Value.absent(),
|
Value<int> movingMillis = const Value.absent(),
|
||||||
Value<double> maxSpeedKmh = const Value.absent(),
|
Value<double> maxSpeedKmh = const Value.absent(),
|
||||||
@@ -2010,6 +2084,7 @@ class $$TripsTableTableManager
|
|||||||
endedAt: endedAt,
|
endedAt: endedAt,
|
||||||
name: name,
|
name: name,
|
||||||
state: state,
|
state: state,
|
||||||
|
activity: activity,
|
||||||
distanceM: distanceM,
|
distanceM: distanceM,
|
||||||
movingMillis: movingMillis,
|
movingMillis: movingMillis,
|
||||||
maxSpeedKmh: maxSpeedKmh,
|
maxSpeedKmh: maxSpeedKmh,
|
||||||
@@ -2023,6 +2098,7 @@ class $$TripsTableTableManager
|
|||||||
Value<int?> endedAt = const Value.absent(),
|
Value<int?> endedAt = const Value.absent(),
|
||||||
Value<String?> name = const Value.absent(),
|
Value<String?> name = const Value.absent(),
|
||||||
required domain.TripState state,
|
required domain.TripState state,
|
||||||
|
Value<domain.Activity> activity = const Value.absent(),
|
||||||
Value<double> distanceM = const Value.absent(),
|
Value<double> distanceM = const Value.absent(),
|
||||||
Value<int> movingMillis = const Value.absent(),
|
Value<int> movingMillis = const Value.absent(),
|
||||||
Value<double> maxSpeedKmh = const Value.absent(),
|
Value<double> maxSpeedKmh = const Value.absent(),
|
||||||
@@ -2034,6 +2110,7 @@ class $$TripsTableTableManager
|
|||||||
endedAt: endedAt,
|
endedAt: endedAt,
|
||||||
name: name,
|
name: name,
|
||||||
state: state,
|
state: state,
|
||||||
|
activity: activity,
|
||||||
distanceM: distanceM,
|
distanceM: distanceM,
|
||||||
movingMillis: movingMillis,
|
movingMillis: movingMillis,
|
||||||
maxSpeedKmh: maxSpeedKmh,
|
maxSpeedKmh: maxSpeedKmh,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
/// must be a no-op rather than opening a duplicate segment.
|
/// must be a no-op rather than opening a duplicate segment.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
import '../domain/activity_profile.dart';
|
||||||
import '../domain/models.dart';
|
import '../domain/models.dart';
|
||||||
import '../stats/ride_statistics.dart';
|
import '../stats/ride_statistics.dart';
|
||||||
import 'database.dart';
|
import 'database.dart';
|
||||||
@@ -75,18 +76,34 @@ class TripRepository {
|
|||||||
///
|
///
|
||||||
/// Adoption rather than rejection is deliberate: after a process kill the trip row
|
/// 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.
|
/// still exists, and the restarted recorder needs to continue it, not start a second.
|
||||||
Future<TripHandle> startTrip(int now) => _db.transaction(() async {
|
///
|
||||||
|
/// [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();
|
final existing = await _db.getActiveTrip();
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
return _adoptOrOpenSegment(existing, now);
|
return _adoptOrOpenSegment(existing, now);
|
||||||
}
|
}
|
||||||
|
final resolvedActivity = activity ?? await _lastUsedActivity();
|
||||||
final tripId = await _db.insertTrip(
|
final tripId = await _db.insertTrip(
|
||||||
Trip(startedAt: now, state: TripState.recording),
|
Trip(
|
||||||
|
startedAt: now,
|
||||||
|
state: TripState.recording,
|
||||||
|
activity: resolvedActivity,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
final segmentId = await _db.insertSegment(tripId, now);
|
final segmentId = await _db.insertSegment(tripId, now);
|
||||||
return TripHandle(tripId, segmentId);
|
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 —
|
/// Closes the open segment and marks the trip paused. The trip itself stays open —
|
||||||
/// only [completeTrip] sets `endedAt`.
|
/// only [completeTrip] sets `endedAt`.
|
||||||
Future<bool> pauseTrip(int now) => _db.transaction(() async {
|
Future<bool> pauseTrip(int now) => _db.transaction(() async {
|
||||||
@@ -180,6 +197,13 @@ class TripRepository {
|
|||||||
|
|
||||||
Future<void> deleteTrip(int id) => _db.deleteTrip(id);
|
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.
|
/// Combines two completed rides into one.
|
||||||
///
|
///
|
||||||
/// The earlier trip survives, the later one's segments and points are re-parented onto
|
/// 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
|
/// 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
|
/// 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 {
|
Future<void> recomputeAggregates(int tripId) async {
|
||||||
|
final trip = await _db.getTrip(tripId);
|
||||||
|
final profile = ActivityProfile.forActivity(
|
||||||
|
trip?.activity ?? Activity.motorcycle,
|
||||||
|
);
|
||||||
final summary = computeSummary(
|
final summary = computeSummary(
|
||||||
await _db.pointsForTrip(tripId),
|
await _db.pointsForTrip(tripId),
|
||||||
segments: await _db.segmentsForTrip(tripId),
|
segments: await _db.segmentsForTrip(tripId),
|
||||||
|
profile: profile,
|
||||||
);
|
);
|
||||||
await _db.updateAggregates(
|
await _db.updateAggregates(
|
||||||
id: tripId,
|
id: tripId,
|
||||||
|
|||||||
112
lib/src/domain/activity_profile.dart
Normal file
112
lib/src/domain/activity_profile.dart
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -24,6 +24,15 @@ library;
|
|||||||
/// OS restarts it mid-ride. Hence both.
|
/// OS restarts it mid-ride. Hence both.
|
||||||
enum TripState { recording, paused, completed }
|
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.
|
/// One ride, from pressing Start to pressing Stop.
|
||||||
///
|
///
|
||||||
/// The aggregate fields are denormalised on purpose. They are accumulated as points
|
/// The aggregate fields are denormalised on purpose. They are accumulated as points
|
||||||
@@ -36,6 +45,7 @@ class Trip {
|
|||||||
this.endedAt,
|
this.endedAt,
|
||||||
this.name,
|
this.name,
|
||||||
this.state = TripState.recording,
|
this.state = TripState.recording,
|
||||||
|
this.activity = Activity.motorcycle,
|
||||||
this.distanceM = 0.0,
|
this.distanceM = 0.0,
|
||||||
this.movingMillis = 0,
|
this.movingMillis = 0,
|
||||||
this.maxSpeedKmh = 0.0,
|
this.maxSpeedKmh = 0.0,
|
||||||
@@ -53,6 +63,9 @@ class Trip {
|
|||||||
final String? name;
|
final String? name;
|
||||||
|
|
||||||
final TripState state;
|
final TripState state;
|
||||||
|
|
||||||
|
/// Defaults to motorcycle, matching every trip recorded before this field existed.
|
||||||
|
final Activity activity;
|
||||||
final double distanceM;
|
final double distanceM;
|
||||||
final int movingMillis;
|
final int movingMillis;
|
||||||
final double maxSpeedKmh;
|
final double maxSpeedKmh;
|
||||||
@@ -69,6 +82,7 @@ class Trip {
|
|||||||
int? endedAt,
|
int? endedAt,
|
||||||
String? name,
|
String? name,
|
||||||
TripState? state,
|
TripState? state,
|
||||||
|
Activity? activity,
|
||||||
double? distanceM,
|
double? distanceM,
|
||||||
int? movingMillis,
|
int? movingMillis,
|
||||||
double? maxSpeedKmh,
|
double? maxSpeedKmh,
|
||||||
@@ -80,6 +94,7 @@ class Trip {
|
|||||||
endedAt: endedAt ?? this.endedAt,
|
endedAt: endedAt ?? this.endedAt,
|
||||||
name: name ?? this.name,
|
name: name ?? this.name,
|
||||||
state: state ?? this.state,
|
state: state ?? this.state,
|
||||||
|
activity: activity ?? this.activity,
|
||||||
distanceM: distanceM ?? this.distanceM,
|
distanceM: distanceM ?? this.distanceM,
|
||||||
movingMillis: movingMillis ?? this.movingMillis,
|
movingMillis: movingMillis ?? this.movingMillis,
|
||||||
maxSpeedKmh: maxSpeedKmh ?? this.maxSpeedKmh,
|
maxSpeedKmh: maxSpeedKmh ?? this.maxSpeedKmh,
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import 'dart:async';
|
|||||||
import 'package:synchronized/synchronized.dart';
|
import 'package:synchronized/synchronized.dart';
|
||||||
|
|
||||||
import '../data/trip_repository.dart';
|
import '../data/trip_repository.dart';
|
||||||
|
import '../domain/activity_profile.dart';
|
||||||
import '../domain/models.dart';
|
import '../domain/models.dart';
|
||||||
import '../telemetry/live_telemetry.dart';
|
import '../telemetry/live_telemetry.dart';
|
||||||
import '../telemetry/telemetry.dart';
|
import '../telemetry/telemetry.dart';
|
||||||
@@ -93,7 +94,14 @@ class RecordingEngine {
|
|||||||
StreamSubscription<LocationFix>? _subscription;
|
StreamSubscription<LocationFix>? _subscription;
|
||||||
Timer? _flushTimer;
|
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.
|
// Read by the fix callback, written by the lifecycle path.
|
||||||
int _currentTripId = 0;
|
int _currentTripId = 0;
|
||||||
@@ -132,7 +140,15 @@ class RecordingEngine {
|
|||||||
|
|
||||||
await _writeLock.synchronized(() async {
|
await _writeLock.synchronized(() async {
|
||||||
if (handle.tripId != _currentTripId) {
|
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);
|
await _seedAccumulator(handle.tripId);
|
||||||
}
|
}
|
||||||
// A new segment must not measure distance back to the pre-pause position.
|
// A new segment must not measure distance back to the pre-pause position.
|
||||||
@@ -233,8 +249,9 @@ class RecordingEngine {
|
|||||||
// avoids.
|
// avoids.
|
||||||
final handle = await _repo.resumeIntoNewSegment(_now());
|
final handle = await _repo.resumeIntoNewSegment(_now());
|
||||||
if (handle == null) break;
|
if (handle == null) break;
|
||||||
|
_activeProfile = ActivityProfile.forActivity(trip!.activity);
|
||||||
await _writeLock.synchronized(() async {
|
await _writeLock.synchronized(() async {
|
||||||
_accumulator.reset();
|
_accumulator = Accumulator(profile: _activeProfile);
|
||||||
await _seedAccumulator(handle.tripId);
|
await _seedAccumulator(handle.tripId);
|
||||||
_accumulator.onSegmentChanged();
|
_accumulator.onSegmentChanged();
|
||||||
});
|
});
|
||||||
@@ -246,6 +263,7 @@ class RecordingEngine {
|
|||||||
_setState(RecorderState.recording);
|
_setState(RecorderState.recording);
|
||||||
case TripState.paused:
|
case TripState.paused:
|
||||||
_currentTripId = trip!.id;
|
_currentTripId = trip!.id;
|
||||||
|
_activeProfile = ActivityProfile.forActivity(trip.activity);
|
||||||
_setState(RecorderState.paused);
|
_setState(RecorderState.paused);
|
||||||
case TripState.completed:
|
case TripState.completed:
|
||||||
case null:
|
case null:
|
||||||
@@ -263,7 +281,9 @@ class RecordingEngine {
|
|||||||
|
|
||||||
/// Called for every GPS fix. **Must not block and must not touch the database.**
|
/// Called for every GPS fix. **Must not block and must not touch the database.**
|
||||||
void _onFix(LocationFix fix) {
|
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
|
// 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.
|
// lands in the segment it was recorded during, not the next one.
|
||||||
@@ -277,7 +297,10 @@ class RecordingEngine {
|
|||||||
timestamp: fix.timestamp,
|
timestamp: fix.timestamp,
|
||||||
latitude: fix.latitude,
|
latitude: fix.latitude,
|
||||||
longitude: fix.longitude,
|
longitude: fix.longitude,
|
||||||
speedKmh: sanitizeSpeedKmh(msToKmh(fix.speedMps)),
|
speedKmh: sanitizeSpeedKmh(
|
||||||
|
msToKmh(fix.speedMps),
|
||||||
|
floorKmh: _activeProfile.noiseFloorKmh,
|
||||||
|
),
|
||||||
altitudeM: fix.altitudeM,
|
altitudeM: fix.altitudeM,
|
||||||
accuracyM: fix.accuracyM,
|
accuracyM: fix.accuracyM,
|
||||||
bearingDeg: fix.bearingDeg,
|
bearingDeg: fix.bearingDeg,
|
||||||
|
|||||||
@@ -16,12 +16,24 @@ library;
|
|||||||
|
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import '../domain/activity_profile.dart';
|
||||||
import '../domain/models.dart';
|
import '../domain/models.dart';
|
||||||
import '../geo/geo.dart';
|
import '../geo/geo.dart';
|
||||||
import '../stats/ride_statistics.dart';
|
import '../stats/ride_statistics.dart';
|
||||||
import '../telemetry/telemetry.dart';
|
|
||||||
|
|
||||||
class Accumulator {
|
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.
|
/// The last point of the *previous* batch.
|
||||||
///
|
///
|
||||||
/// Without this, distance restarts at every flush boundary and silently under-reports
|
/// Without this, distance restarts at every flush boundary and silently under-reports
|
||||||
@@ -29,7 +41,7 @@ class Accumulator {
|
|||||||
/// until they compare against an odometer.
|
/// until they compare against an odometer.
|
||||||
TrackPoint? _lastPoint;
|
TrackPoint? _lastPoint;
|
||||||
|
|
||||||
ElevationAccumulator _elevation = ElevationAccumulator();
|
ElevationAccumulator _elevation;
|
||||||
double _restoredElevationM = 0.0;
|
double _restoredElevationM = 0.0;
|
||||||
|
|
||||||
double _distanceM = 0.0;
|
double _distanceM = 0.0;
|
||||||
@@ -61,7 +73,7 @@ class Accumulator {
|
|||||||
final dt = point.timestamp - prev.timestamp;
|
final dt = point.timestamp - prev.timestamp;
|
||||||
if (dt >= 1 &&
|
if (dt >= 1 &&
|
||||||
dt <= maxSampleGapMillis &&
|
dt <= maxSampleGapMillis &&
|
||||||
point.speedKmh >= speedNoiseFloorKmh) {
|
point.speedKmh >= profile.noiseFloorKmh) {
|
||||||
_movingMillis += dt;
|
_movingMillis += dt;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,13 +105,19 @@ class Accumulator {
|
|||||||
// Elevation cannot be resumed mid-run from a scalar total, so the 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
|
// restarts and only gain from here on is added to the persisted figure. The
|
||||||
// authoritative recomputation at trip completion corrects any drift.
|
// authoritative recomputation at trip completion corrects any drift.
|
||||||
_elevation = ElevationAccumulator();
|
_elevation = ElevationAccumulator(
|
||||||
|
windowSize: profile.elevationWindowSamples,
|
||||||
|
thresholdM: profile.elevationThresholdM,
|
||||||
|
);
|
||||||
_restoredElevationM = elevationGainM;
|
_restoredElevationM = elevationGainM;
|
||||||
}
|
}
|
||||||
|
|
||||||
void reset() {
|
void reset() {
|
||||||
_lastPoint = null;
|
_lastPoint = null;
|
||||||
_elevation = ElevationAccumulator();
|
_elevation = ElevationAccumulator(
|
||||||
|
windowSize: profile.elevationWindowSamples,
|
||||||
|
thresholdM: profile.elevationThresholdM,
|
||||||
|
);
|
||||||
_distanceM = 0.0;
|
_distanceM = 0.0;
|
||||||
_movingMillis = 0;
|
_movingMillis = 0;
|
||||||
_maxSpeedKmh = 0.0;
|
_maxSpeedKmh = 0.0;
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ library;
|
|||||||
import 'dart:collection';
|
import 'dart:collection';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import '../domain/activity_profile.dart';
|
||||||
import '../domain/models.dart';
|
import '../domain/models.dart';
|
||||||
import '../geo/geo.dart';
|
import '../geo/geo.dart';
|
||||||
import '../telemetry/telemetry.dart';
|
|
||||||
|
|
||||||
class RideSummary {
|
class RideSummary {
|
||||||
const RideSummary({
|
const RideSummary({
|
||||||
@@ -128,6 +128,7 @@ Map<int, List<TrackPoint>> _groupBySegment(List<TrackPoint> points) {
|
|||||||
RideSummary computeSummary(
|
RideSummary computeSummary(
|
||||||
List<TrackPoint> points, {
|
List<TrackPoint> points, {
|
||||||
List<Segment> segments = const [],
|
List<Segment> segments = const [],
|
||||||
|
ActivityProfile profile = ActivityProfile.motorcycle,
|
||||||
}) {
|
}) {
|
||||||
if (points.isEmpty) return RideSummary.empty;
|
if (points.isEmpty) return RideSummary.empty;
|
||||||
|
|
||||||
@@ -135,7 +136,10 @@ RideSummary computeSummary(
|
|||||||
var movingMillis = 0;
|
var movingMillis = 0;
|
||||||
var maxSpeedKmh = 0.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
|
// Grouping by segment is what keeps a pause from inventing distance: points either
|
||||||
// side of a gas-station stop can be kilometres apart.
|
// side of a gas-station stop can be kilometres apart.
|
||||||
@@ -155,7 +159,7 @@ RideSummary computeSummary(
|
|||||||
final dt = point.timestamp - previous.timestamp;
|
final dt = point.timestamp - previous.timestamp;
|
||||||
if (dt >= 1 &&
|
if (dt >= 1 &&
|
||||||
dt <= maxSampleGapMillis &&
|
dt <= maxSampleGapMillis &&
|
||||||
point.speedKmh >= speedNoiseFloorKmh) {
|
point.speedKmh >= profile.noiseFloorKmh) {
|
||||||
movingMillis += dt;
|
movingMillis += dt;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
/// "max speed" is not set by GPS noise while the phone sits in a pocket.
|
||||||
const double speedNoiseFloorKmh = 1.5;
|
const double speedNoiseFloorKmh = 1.5;
|
||||||
|
|
||||||
double sanitizeSpeedKmh(double raw) {
|
/// [floorKmh] defaults to the motorcycle noise floor; callers with an activity in hand
|
||||||
if (!raw.isFinite || raw < speedNoiseFloorKmh) return 0.0;
|
/// 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;
|
return raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
29
lib/src/ui/activity_display.dart
Normal file
29
lib/src/ui/activity_display.dart
Normal 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,
|
||||||
|
};
|
||||||
@@ -8,10 +8,12 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../app/providers.dart';
|
import '../../app/providers.dart';
|
||||||
import '../../export/export_manager.dart';
|
import '../../domain/activity_profile.dart';
|
||||||
import '../../domain/models.dart';
|
import '../../domain/models.dart';
|
||||||
|
import '../../export/export_manager.dart';
|
||||||
import '../../stats/ride_statistics.dart';
|
import '../../stats/ride_statistics.dart';
|
||||||
import '../../telemetry/telemetry.dart';
|
import '../../telemetry/telemetry.dart';
|
||||||
|
import '../activity_display.dart';
|
||||||
import '../components/ride_map.dart';
|
import '../components/ride_map.dart';
|
||||||
import '../components/stats.dart';
|
import '../components/stats.dart';
|
||||||
import '../format.dart';
|
import '../format.dart';
|
||||||
@@ -50,13 +52,17 @@ final tripDetailProvider = FutureProvider.autoDispose.family<TripDetail?, int>((
|
|||||||
|
|
||||||
final points = await repo.pointsForTrip(tripId);
|
final points = await repo.pointsForTrip(tripId);
|
||||||
final segments = await repo.segmentsForTrip(tripId);
|
final segments = await repo.segmentsForTrip(tripId);
|
||||||
|
final activityProfile = ActivityProfile.forActivity(trip.activity);
|
||||||
|
|
||||||
return TripDetail(
|
return TripDetail(
|
||||||
trip: trip,
|
trip: trip,
|
||||||
summary: computeSummary(points, segments: segments),
|
summary: computeSummary(points, segments: segments, profile: activityProfile),
|
||||||
points: points,
|
points: points,
|
||||||
segments: segments,
|
segments: segments,
|
||||||
histogram: speedHistogram(points),
|
histogram: speedHistogram(
|
||||||
|
points,
|
||||||
|
bucketKmh: activityProfile.histogramBucketKmh,
|
||||||
|
),
|
||||||
profile: elevationProfile(points),
|
profile: elevationProfile(points),
|
||||||
mapEnabled: ref.watch(mapEnabledProvider),
|
mapEnabled: ref.watch(mapEnabledProvider),
|
||||||
);
|
);
|
||||||
@@ -101,7 +107,10 @@ class TripDetailScreen extends ConsumerWidget {
|
|||||||
message: 'This ride no longer exists.',
|
message: 'This ride no longer exists.',
|
||||||
icon: Icons.help_outline,
|
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(
|
AsyncValue(hasError: true, :final error) => EmptyState(
|
||||||
message: 'Could not load this ride.\n$error',
|
message: 'Could not load this ride.\n$error',
|
||||||
),
|
),
|
||||||
@@ -174,12 +183,46 @@ class TripDetailScreen extends ConsumerWidget {
|
|||||||
await ref.read(tripRepositoryProvider).renameTrip(trip.id, name);
|
await ref.read(tripRepositoryProvider).renameTrip(trip.id, name);
|
||||||
ref.invalidate(tripDetailProvider(trip.id));
|
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 {
|
class _Body extends StatelessWidget {
|
||||||
const _Body({required this.detail});
|
const _Body({required this.detail, required this.onEditActivity});
|
||||||
|
|
||||||
final TripDetail detail;
|
final TripDetail detail;
|
||||||
|
final VoidCallback onEditActivity;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -195,6 +238,13 @@ class _Body extends StatelessWidget {
|
|||||||
unit: 'km',
|
unit: 'km',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Center(
|
||||||
|
child: _ActivityRow(
|
||||||
|
activity: detail.trip.activity,
|
||||||
|
onTap: onEditActivity,
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
if (detail.mapEnabled)
|
if (detail.mapEnabled)
|
||||||
RideMap(points: detail.points, segments: detail.segments),
|
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 {
|
class _SectionTitle extends StatelessWidget {
|
||||||
const _SectionTitle(this.text);
|
const _SectionTitle(this.text);
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import '../../app/providers.dart';
|
import '../../app/providers.dart';
|
||||||
import '../../domain/models.dart';
|
import '../../domain/models.dart';
|
||||||
import '../../telemetry/telemetry.dart';
|
import '../../telemetry/telemetry.dart';
|
||||||
|
import '../activity_display.dart';
|
||||||
import '../components/stats.dart';
|
import '../components/stats.dart';
|
||||||
import '../format.dart';
|
import '../format.dart';
|
||||||
|
|
||||||
@@ -226,7 +227,7 @@ class _TripTile extends StatelessWidget {
|
|||||||
selected ? Icons.check_circle : Icons.circle_outlined,
|
selected ? Icons.check_circle : Icons.circle_outlined,
|
||||||
color: selected ? colors.primary : colors.outline,
|
color: selected ? colors.primary : colors.outline,
|
||||||
)
|
)
|
||||||
: null,
|
: Icon(activityIcon(trip.activity), color: colors.outline),
|
||||||
title: Text(
|
title: Text(
|
||||||
tripLabel(trip),
|
tripLabel(trip),
|
||||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||||
|
|||||||
@@ -973,7 +973,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.0+eol"
|
version: "0.7.0+eol"
|
||||||
sqlite3:
|
sqlite3:
|
||||||
dependency: transitive
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: sqlite3
|
name: sqlite3
|
||||||
sha256: "64b2c63c8232dd20d14b34105a81ebfd74320442e8451f836179ec89986aa478"
|
sha256: "64b2c63c8232dd20d14b34105a81ebfd74320442e8451f836179ec89986aa478"
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ dev_dependencies:
|
|||||||
build_runner: ^2.16.0
|
build_runner: ^2.16.0
|
||||||
mocktail: ^1.0.5
|
mocktail: ^1.0.5
|
||||||
xml: ^6.6.1
|
xml: ^6.6.1
|
||||||
|
sqlite3: ^3.5.1
|
||||||
|
|
||||||
# For information on the generic Dart part of this file, see the
|
# For information on the generic Dart part of this file, see the
|
||||||
# following page: https://dart.dev/tools/pub/pubspec
|
# following page: https://dart.dev/tools/pub/pubspec
|
||||||
|
|||||||
64
test/activity_profile_test.dart
Normal file
64
test/activity_profile_test.dart
Normal file
@@ -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),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
150
test/migration_test.dart
Normal file
150
test/migration_test.dart
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:drift/native.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:rippr/src/data/database.dart';
|
||||||
|
import 'package:rippr/src/domain/models.dart';
|
||||||
|
import 'package:sqlite3/sqlite3.dart' as sqlite3;
|
||||||
|
|
||||||
|
/// V3-01's migration test — the one that matters more than the feature it belongs to.
|
||||||
|
///
|
||||||
|
/// The destructive fallback is gone for good (see the doc comment on [AppDatabase]), so
|
||||||
|
/// this is the port's first real schema migration, and getting the path right here is
|
||||||
|
/// what makes every later one trustworthy.
|
||||||
|
///
|
||||||
|
/// Builds the v1 schema with raw SQL via `package:sqlite3` directly, rather than
|
||||||
|
/// drift_dev's schema-versioning tooling — deliberately: that tooling needs a
|
||||||
|
/// `build.yaml`-declared set of frozen schema snapshots this project has not adopted, and
|
||||||
|
/// a hand-built v1 table is a faithful enough stand-in. It is copied column-for-column
|
||||||
|
/// from `Trips`/`Segments`/`TrackPoints` as they were before the `activity` column was
|
||||||
|
/// added, using the same names — `build.yaml`'s `case_from_dart_to_sql: preserve` means
|
||||||
|
/// there is no snake_case translation to get wrong.
|
||||||
|
void main() {
|
||||||
|
late Directory tempDir;
|
||||||
|
late File dbFile;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
tempDir = await Directory.systemTemp.createTemp('rippr_migration_test_');
|
||||||
|
dbFile = File('${tempDir.path}/v1.sqlite');
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
if (tempDir.existsSync()) tempDir.deleteSync(recursive: true);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Writes a database file containing exactly the v1 schema, seeded with one trip, one
|
||||||
|
/// closed segment, and a handful of points -- then closes it, so Drift can open the
|
||||||
|
/// same file fresh.
|
||||||
|
void seedV1Database() {
|
||||||
|
final raw = sqlite3.sqlite3.open(dbFile.path);
|
||||||
|
raw.execute('''
|
||||||
|
CREATE TABLE trips (
|
||||||
|
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
startedAt INTEGER NOT NULL,
|
||||||
|
endedAt INTEGER NULL,
|
||||||
|
name TEXT NULL,
|
||||||
|
state TEXT NOT NULL,
|
||||||
|
distanceM REAL NOT NULL DEFAULT 0,
|
||||||
|
movingMillis INTEGER NOT NULL DEFAULT 0,
|
||||||
|
maxSpeedKmh REAL NOT NULL DEFAULT 0,
|
||||||
|
elevationGainM REAL NOT NULL DEFAULT 0,
|
||||||
|
pointCount INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_trips_ended ON trips (endedAt);
|
||||||
|
|
||||||
|
CREATE TABLE segments (
|
||||||
|
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
tripId INTEGER NOT NULL REFERENCES trips (id) ON DELETE CASCADE,
|
||||||
|
startedAt INTEGER NOT NULL,
|
||||||
|
endedAt INTEGER NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_segments_trip ON segments (tripId);
|
||||||
|
|
||||||
|
CREATE TABLE track_points (
|
||||||
|
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
tripId INTEGER NOT NULL REFERENCES trips (id) ON DELETE CASCADE,
|
||||||
|
segmentId INTEGER NOT NULL REFERENCES segments (id) ON DELETE CASCADE,
|
||||||
|
timestamp INTEGER NOT NULL,
|
||||||
|
latitude REAL NOT NULL,
|
||||||
|
longitude REAL NOT NULL,
|
||||||
|
speedKmh REAL NOT NULL,
|
||||||
|
altitudeM REAL NOT NULL,
|
||||||
|
accuracyM REAL NOT NULL DEFAULT 0,
|
||||||
|
bearingDeg REAL NOT NULL DEFAULT 0,
|
||||||
|
synced INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_points_trip ON track_points (tripId);
|
||||||
|
CREATE INDEX idx_points_segment ON track_points (segmentId);
|
||||||
|
CREATE INDEX idx_points_synced ON track_points (synced);
|
||||||
|
''');
|
||||||
|
|
||||||
|
raw.execute('''
|
||||||
|
INSERT INTO trips (id, startedAt, endedAt, name, state, distanceM, movingMillis,
|
||||||
|
maxSpeedKmh, elevationGainM, pointCount)
|
||||||
|
VALUES (1, 1000, 5000, 'Old ride', 'completed', 111.2, 4000, 40.0, 12.0, 3);
|
||||||
|
''');
|
||||||
|
raw.execute('''
|
||||||
|
INSERT INTO segments (id, tripId, startedAt, endedAt) VALUES (1, 1, 1000, 5000);
|
||||||
|
''');
|
||||||
|
for (var i = 0; i < 3; i++) {
|
||||||
|
raw.execute('''
|
||||||
|
INSERT INTO track_points
|
||||||
|
(tripId, segmentId, timestamp, latitude, longitude, speedKmh, altitudeM)
|
||||||
|
VALUES (1, 1, ${1000 + i * 1000}, ${51.0 + i * 0.0001}, -114.0, 40.0, 1000.0);
|
||||||
|
''');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drift persists its schema version in SQLite's own user_version pragma. Setting it
|
||||||
|
// to 1 here is what makes AppDatabase (schemaVersion 2) believe an upgrade is due
|
||||||
|
// when it opens this file, rather than treating it as a fresh database.
|
||||||
|
raw.execute('PRAGMA user_version = 1;');
|
||||||
|
raw.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a v1 database migrates and keeps every ride and point', () async {
|
||||||
|
seedV1Database();
|
||||||
|
|
||||||
|
final db = AppDatabase(NativeDatabase(dbFile));
|
||||||
|
addTearDown(db.close);
|
||||||
|
|
||||||
|
final trip = await db.getTrip(1);
|
||||||
|
expect(trip, isNotNull, reason: 'the pre-existing ride must survive the migration');
|
||||||
|
expect(trip!.name, 'Old ride');
|
||||||
|
expect(trip.distanceM, closeTo(111.2, 1e-9));
|
||||||
|
expect(trip.pointCount, 3);
|
||||||
|
expect(
|
||||||
|
trip.activity,
|
||||||
|
Activity.motorcycle,
|
||||||
|
reason: 'a ride recorded before activity existed must default to motorcycle, '
|
||||||
|
'not be left null or reject the row',
|
||||||
|
);
|
||||||
|
|
||||||
|
final points = await db.pointsForTrip(1);
|
||||||
|
expect(points.length, 3, reason: 'no point may be lost in the upgrade');
|
||||||
|
|
||||||
|
final segments = await db.segmentsForTrip(1);
|
||||||
|
expect(segments.length, 1);
|
||||||
|
|
||||||
|
// The column is genuinely usable afterwards, not just present with a default.
|
||||||
|
await db.setTripActivity(1, Activity.bicycle);
|
||||||
|
expect((await db.getTrip(1))!.activity, Activity.bicycle);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a v1 database with several rides migrates all of them', () async {
|
||||||
|
seedV1Database();
|
||||||
|
final raw = sqlite3.sqlite3.open(dbFile.path);
|
||||||
|
raw.execute('''
|
||||||
|
INSERT INTO trips (startedAt, endedAt, state, distanceM, pointCount)
|
||||||
|
VALUES (10000, 15000, 'completed', 500.0, 10),
|
||||||
|
(20000, 25000, 'completed', 1000.0, 20);
|
||||||
|
''');
|
||||||
|
raw.close();
|
||||||
|
|
||||||
|
final db = AppDatabase(NativeDatabase(dbFile));
|
||||||
|
addTearDown(db.close);
|
||||||
|
|
||||||
|
final all = await db.watchCompletedTrips().first;
|
||||||
|
expect(all.length, 3);
|
||||||
|
expect(all.every((t) => t.activity == Activity.motorcycle), isTrue);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
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/domain/models.dart';
|
||||||
import 'package:rippr/src/stats/ride_statistics.dart';
|
import 'package:rippr/src/stats/ride_statistics.dart';
|
||||||
|
|
||||||
@@ -299,4 +300,57 @@ void main() {
|
|||||||
expect(elevationProfile([point(ts: 0)]).length, 1);
|
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),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:drift/native.dart';
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:rippr/src/data/database.dart';
|
import 'package:rippr/src/data/database.dart';
|
||||||
import 'package:rippr/src/data/trip_repository.dart';
|
import 'package:rippr/src/data/trip_repository.dart';
|
||||||
|
import 'package:rippr/src/domain/activity_profile.dart';
|
||||||
import 'package:rippr/src/domain/models.dart';
|
import 'package:rippr/src/domain/models.dart';
|
||||||
|
|
||||||
/// Ported from `com.rippr.data.TripRepositoryTest` and `MergeTest`.
|
/// 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', () {
|
group('process death', () {
|
||||||
test('a fresh repository over the same database sees the active trip',
|
test('a fresh repository over the same database sees the active trip',
|
||||||
() async {
|
() async {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:rippr/src/app/providers.dart';
|
|||||||
import 'package:rippr/src/data/database.dart';
|
import 'package:rippr/src/data/database.dart';
|
||||||
import 'package:rippr/src/data/trip_repository.dart';
|
import 'package:rippr/src/data/trip_repository.dart';
|
||||||
import 'package:rippr/src/domain/models.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/recording/location_source.dart';
|
||||||
import 'package:rippr/src/ui/detail/trip_detail_screen.dart';
|
import 'package:rippr/src/ui/detail/trip_detail_screen.dart';
|
||||||
import 'package:rippr/src/ui/record/record_screen.dart';
|
import 'package:rippr/src/ui/record/record_screen.dart';
|
||||||
@@ -282,6 +283,27 @@ void main() {
|
|||||||
|
|
||||||
expect(await repo.tripById(a), isNull);
|
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<Icon>(
|
||||||
|
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', () {
|
group('trip detail', () {
|
||||||
@@ -340,5 +362,23 @@ void main() {
|
|||||||
expect(find.text('Coast run'), findsOneWidget);
|
expect(find.text('Coast run'), findsOneWidget);
|
||||||
expect((await repo.tripById(id))!.name, 'Coast run');
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user