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:
@@ -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,17 +76,33 @@ class TripRepository {
|
||||
///
|
||||
/// Adoption rather than rejection is deliberate: after a process kill the trip row
|
||||
/// still exists, and the restarted recorder needs to continue it, not start a second.
|
||||
Future<TripHandle> startTrip(int now) => _db.transaction(() async {
|
||||
final existing = await _db.getActiveTrip();
|
||||
if (existing != null) {
|
||||
return _adoptOrOpenSegment(existing, now);
|
||||
}
|
||||
final tripId = await _db.insertTrip(
|
||||
Trip(startedAt: now, state: TripState.recording),
|
||||
);
|
||||
final segmentId = await _db.insertSegment(tripId, now);
|
||||
return TripHandle(tripId, segmentId);
|
||||
});
|
||||
///
|
||||
/// [activity] defaults to whatever was last used ([_lastUsedActivity]) rather than
|
||||
/// requiring a picker in front of Start — the founding premise is press-and-go with
|
||||
/// gloves on. Pass it explicitly only where something else has already chosen it
|
||||
/// (there is nothing that does yet).
|
||||
Future<TripHandle> startTrip(int now, {Activity? activity}) =>
|
||||
_db.transaction(() async {
|
||||
final existing = await _db.getActiveTrip();
|
||||
if (existing != null) {
|
||||
return _adoptOrOpenSegment(existing, now);
|
||||
}
|
||||
final resolvedActivity = activity ?? await _lastUsedActivity();
|
||||
final tripId = await _db.insertTrip(
|
||||
Trip(
|
||||
startedAt: now,
|
||||
state: TripState.recording,
|
||||
activity: resolvedActivity,
|
||||
),
|
||||
);
|
||||
final segmentId = await _db.insertSegment(tripId, now);
|
||||
return TripHandle(tripId, segmentId);
|
||||
});
|
||||
|
||||
Future<Activity> _lastUsedActivity() async {
|
||||
final row = await _db.mostRecentTrip();
|
||||
return row?.activity ?? Activity.motorcycle;
|
||||
}
|
||||
|
||||
/// Closes the open segment and marks the trip paused. The trip itself stays open —
|
||||
/// only [completeTrip] sets `endedAt`.
|
||||
@@ -180,6 +197,13 @@ class TripRepository {
|
||||
|
||||
Future<void> deleteTrip(int id) => _db.deleteTrip(id);
|
||||
|
||||
/// Changes a trip's activity and recomputes its aggregates under the new profile — a
|
||||
/// noise floor or elevation window tuned for a motorcycle may not fit a walk.
|
||||
Future<void> setActivity(int tripId, Activity activity) async {
|
||||
await _db.setTripActivity(tripId, activity);
|
||||
await recomputeAggregates(tripId);
|
||||
}
|
||||
|
||||
/// Combines two completed rides into one.
|
||||
///
|
||||
/// The earlier trip survives, the later one's segments and points are re-parented onto
|
||||
@@ -228,11 +252,17 @@ class TripRepository {
|
||||
///
|
||||
/// This is the authoritative pass. The recorder's live accumulation is an estimate, so
|
||||
/// running this on completion means a mid-ride process kill cannot leave permanently
|
||||
/// skewed totals.
|
||||
/// skewed totals. Reads the trip's own activity to pick the right profile, so this
|
||||
/// stays correct after [setActivity] as well as after a normal completion.
|
||||
Future<void> recomputeAggregates(int tripId) async {
|
||||
final trip = await _db.getTrip(tripId);
|
||||
final profile = ActivityProfile.forActivity(
|
||||
trip?.activity ?? Activity.motorcycle,
|
||||
);
|
||||
final summary = computeSummary(
|
||||
await _db.pointsForTrip(tripId),
|
||||
segments: await _db.segmentsForTrip(tripId),
|
||||
profile: profile,
|
||||
);
|
||||
await _db.updateAggregates(
|
||||
id: tripId,
|
||||
|
||||
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),
|
||||
|
||||
Reference in New Issue
Block a user