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
|
||||
|
||||
**Phase** Foundations · **Depends on** nothing · **Size** M · **Status** Not started
|
||||
**Phase** Foundations · **Depends on** nothing · **Size** M · **Status** Done
|
||||
|
||||
## Goal
|
||||
Every ride records what it was done on — motorcycle, bicycle, scooter, skateboard,
|
||||
@@ -67,3 +67,31 @@ Default to the last activity used; make it editable on trip detail next to renam
|
||||
|
||||
## Out of scope
|
||||
Per-activity totals or a stats screen. GPX `<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>()();
|
||||
|
||||
/// 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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,18 +76,34 @@ 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 {
|
||||
///
|
||||
/// [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),
|
||||
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`.
|
||||
Future<bool> pauseTrip(int now) => _db.transaction(() async {
|
||||
@@ -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,
|
||||
|
||||
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.
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
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 '../../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);
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -973,7 +973,7 @@ packages:
|
||||
source: hosted
|
||||
version: "0.7.0+eol"
|
||||
sqlite3:
|
||||
dependency: transitive
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: sqlite3
|
||||
sha256: "64b2c63c8232dd20d14b34105a81ebfd74320442e8451f836179ec89986aa478"
|
||||
|
||||
@@ -65,6 +65,7 @@ dev_dependencies:
|
||||
build_runner: ^2.16.0
|
||||
mocktail: ^1.0.5
|
||||
xml: ^6.6.1
|
||||
sqlite3: ^3.5.1
|
||||
|
||||
# For information on the generic Dart part of this file, see the
|
||||
# following page: https://dart.dev/tools/pub/pubspec
|
||||
|
||||
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:rippr/src/domain/activity_profile.dart';
|
||||
import 'package:rippr/src/domain/models.dart';
|
||||
import 'package:rippr/src/stats/ride_statistics.dart';
|
||||
|
||||
@@ -299,4 +300,57 @@ void main() {
|
||||
expect(elevationProfile([point(ts: 0)]).length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
group('activity profile changes real output (V3-01)', () {
|
||||
test('a slow walk reads as stationary under the motorcycle floor, but not under '
|
||||
"walking's own", () {
|
||||
// 20 fixes a second apart, each ~0.7 km/h -- real walking pace, comfortably under
|
||||
// the motorcycle noise floor (1.5) but above the walking one (0.3). This is the
|
||||
// whole point of activity profiles: without one, a recorded walk would show
|
||||
// 00:00:00 moving time despite every fix showing genuine movement.
|
||||
final points = List.generate(
|
||||
20,
|
||||
(i) => point(ts: i * 1000, lat: 51.0 + i * 0.0000065, speed: 0.7),
|
||||
);
|
||||
|
||||
final asMotorcycle =
|
||||
computeSummary(points, profile: ActivityProfile.motorcycle);
|
||||
final asWalking = computeSummary(points, profile: ActivityProfile.walking);
|
||||
|
||||
expect(asMotorcycle.movingMillis, 0,
|
||||
reason: 'the motorcycle floor must reject real walking speed as noise');
|
||||
expect(asWalking.movingMillis, greaterThan(0),
|
||||
reason: "the walking profile must recognise its own pace as movement");
|
||||
});
|
||||
|
||||
test('elevation window size changes what counts as a real climb', () {
|
||||
// A short, shallow rise across only a handful of samples. A wider averaging
|
||||
// window (running/walking) smooths it away as noise; the narrower motorcycle
|
||||
// window banks more of it as real.
|
||||
final points = List.generate(
|
||||
10,
|
||||
(i) => point(ts: i * 1000, alt: 1000.0 + i * 0.5),
|
||||
);
|
||||
|
||||
final motorcycle =
|
||||
computeSummary(points, profile: ActivityProfile.motorcycle).elevationGainM;
|
||||
final running =
|
||||
computeSummary(points, profile: ActivityProfile.running).elevationGainM;
|
||||
|
||||
expect(running, lessThanOrEqualTo(motorcycle),
|
||||
reason: "running's wider smoothing window must not report MORE gain than "
|
||||
"motorcycle's narrower one on the same climb");
|
||||
});
|
||||
|
||||
test('defaults to the motorcycle profile when none is given', () {
|
||||
final points = List.generate(
|
||||
5,
|
||||
(i) => point(ts: i * 1000, speed: 40.0),
|
||||
);
|
||||
expect(
|
||||
computeSummary(points),
|
||||
computeSummary(points, profile: ActivityProfile.motorcycle),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:rippr/src/data/database.dart';
|
||||
import 'package:rippr/src/data/trip_repository.dart';
|
||||
import 'package:rippr/src/domain/activity_profile.dart';
|
||||
import 'package:rippr/src/domain/models.dart';
|
||||
|
||||
/// Ported from `com.rippr.data.TripRepositoryTest` and `MergeTest`.
|
||||
@@ -187,6 +188,84 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Activity type — V3-01
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
group('activity', () {
|
||||
test('a brand new database defaults a fresh trip to motorcycle', () async {
|
||||
final h = await repo.startTrip(1000);
|
||||
expect((await repo.tripById(h.tripId))!.activity, Activity.motorcycle);
|
||||
});
|
||||
|
||||
test('starting a new trip defaults to whatever activity was last used, '
|
||||
'with no picker required', () async {
|
||||
final first = await repo.startTrip(1000);
|
||||
await repo.completeTrip(2000);
|
||||
await repo.setActivity(first.tripId, Activity.walking);
|
||||
|
||||
final second = await repo.startTrip(3000);
|
||||
|
||||
expect((await repo.tripById(second.tripId))!.activity, Activity.walking,
|
||||
reason: 'the new ride should inherit the most recently created trip\'s '
|
||||
'activity, not silently reset to motorcycle');
|
||||
});
|
||||
|
||||
test('an explicit activity overrides the last-used default', () async {
|
||||
final h = await repo.startTrip(1000, activity: Activity.bicycle);
|
||||
expect((await repo.tripById(h.tripId))!.activity, Activity.bicycle);
|
||||
});
|
||||
|
||||
test('resuming an active trip never changes its activity', () async {
|
||||
final h = await repo.startTrip(1000, activity: Activity.running);
|
||||
await repo.pauseTrip(2000);
|
||||
// Started again with no explicit activity -- must adopt the paused trip's own,
|
||||
// not fall back to whatever "last used" would otherwise resolve to.
|
||||
final resumed = await repo.startTrip(3000);
|
||||
|
||||
expect(resumed.tripId, h.tripId);
|
||||
expect((await repo.tripById(h.tripId))!.activity, Activity.running);
|
||||
});
|
||||
|
||||
test('setActivity recomputes aggregates under the new profile', () async {
|
||||
// A slow walking pace: under the motorcycle noise floor (1.5 km/h) but above
|
||||
// walking's own (0.3), so moving time genuinely differs between the two profiles.
|
||||
final h = await repo.startTrip(1000, activity: Activity.motorcycle);
|
||||
for (var i = 0; i < 10; i++) {
|
||||
await addPoint(h, 1000 + i * 1000, speed: 0.7);
|
||||
}
|
||||
await repo.completeTrip(11000);
|
||||
// completeTrip() alone does not recompute aggregates -- the recorder does that
|
||||
// separately on stop(). Mirror that here rather than relying on a leftover value.
|
||||
await repo.recomputeAggregates(h.tripId);
|
||||
|
||||
final asMotorcycle = (await repo.tripById(h.tripId))!;
|
||||
expect(asMotorcycle.movingMillis, 0,
|
||||
reason: 'a motorcycle profile must read a 0.7 km/h pace as noise');
|
||||
|
||||
await repo.setActivity(h.tripId, Activity.walking);
|
||||
|
||||
final asWalking = (await repo.tripById(h.tripId))!;
|
||||
expect(asWalking.activity, Activity.walking);
|
||||
expect(asWalking.movingMillis, greaterThan(0),
|
||||
reason: 'recomputing under the walking profile must recognise the same '
|
||||
'fixes as real movement');
|
||||
// Sanity check that this really did come from ActivityProfile.walking and not a
|
||||
// coincidence -- computing with the profile explicitly must agree.
|
||||
expect(
|
||||
asWalking.movingMillis,
|
||||
greaterThan(asMotorcycle.movingMillis),
|
||||
);
|
||||
// The bicycle/motorcycle-equivalent profile does not exist as a category here;
|
||||
// just confirm the profile object itself expresses the difference this test
|
||||
// exercises indirectly through the repository.
|
||||
expect(
|
||||
ActivityProfile.walking.noiseFloorKmh,
|
||||
lessThan(ActivityProfile.motorcycle.noiseFloorKmh),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('process death', () {
|
||||
test('a fresh repository over the same database sees the active trip',
|
||||
() async {
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:rippr/src/app/providers.dart';
|
||||
import 'package:rippr/src/data/database.dart';
|
||||
import 'package:rippr/src/data/trip_repository.dart';
|
||||
import 'package:rippr/src/domain/models.dart';
|
||||
import 'package:rippr/src/ui/activity_display.dart';
|
||||
import 'package:rippr/src/recording/location_source.dart';
|
||||
import 'package:rippr/src/ui/detail/trip_detail_screen.dart';
|
||||
import 'package:rippr/src/ui/record/record_screen.dart';
|
||||
@@ -282,6 +283,27 @@ void main() {
|
||||
|
||||
expect(await repo.tripById(a), isNull);
|
||||
});
|
||||
|
||||
screenTest('a tile shows its own activity icon (V3-01)', (tester) async {
|
||||
final motorcycleTrip =
|
||||
await seedCompletedTrip(startedAt: 1000, endedAt: 5000, name: 'Ride');
|
||||
final bikeTrip =
|
||||
await seedCompletedTrip(startedAt: 10000, endedAt: 15000, name: 'Bike');
|
||||
await repo.setActivity(bikeTrip, Activity.bicycle);
|
||||
|
||||
await tester.pumpWidget(host(const TripsScreen()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
Icon iconFor(int tripId) => tester.widget<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', () {
|
||||
@@ -340,5 +362,23 @@ void main() {
|
||||
expect(find.text('Coast run'), findsOneWidget);
|
||||
expect((await repo.tripById(id))!.name, 'Coast run');
|
||||
});
|
||||
|
||||
screenTest('editing the activity updates the row and recomputes aggregates',
|
||||
(tester) async {
|
||||
final id = await seedCompletedTrip(startedAt: 1000, endedAt: 5000);
|
||||
|
||||
await tester.pumpWidget(host(TripDetailScreen(tripId: id), map: false));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text(activityLabel(Activity.motorcycle)), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byKey(const Key('activity-row')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(Key('activity-${Activity.walking.name}')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text(activityLabel(Activity.walking)), findsOneWidget);
|
||||
expect((await repo.tripById(id))!.activity, Activity.walking);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user