V3-01: activity type per ride

Adds Activity (motorcycle/bicycle/scooter/skateboard/running/walking/other) as
a column on Trip, and threads it through as real behaviour rather than a label:
ActivityProfile (domain/activity_profile.dart) carries a noise floor, accuracy
gate, histogram bucket width, and elevation smoothing window/threshold per
activity, consumed by sanitizeSpeedKmh, isUsableFix, computeSummary, Accumulator
and speedHistogram. The motorcycle profile reproduces the exact constants the
app shipped with before this existed, and a test asserts they never drift apart.

This is the port's first real migration: schemaVersion 1 -> 2,
m.addColumn(trips, trips.activity) with a motorcycle default so every existing
row survives unmodified. Proven with a hand-built v1 SQLite file (raw sqlite3,
not drift_dev's schema tooling) that a real database with real rides upgrades
and keeps every trip, segment and point.

No picker in front of Start: a new trip defaults to whichever activity was most
recently used, derived live from the trips table rather than duplicated into a
Config field. Editable afterwards on trip detail, which recomputes aggregates
under the new profile immediately -- a walking pace that reads as noise under a
motorcycle's floor reads as real movement once the activity is corrected, and
there's a test proving exactly that transition.

The widget-test pass for the activity-picker sheet caught a real overflow bug:
seven options overflowed a Column-based bottom sheet the same way the record
screen once did. Fixed with a scrollable ListView + isScrollControlled, same
shape as that earlier fix.

188 tests (171 -> 188): 2 migration, 5 ActivityProfile, 3 profile-threading
proofs in computeSummary, 5 repository, 2 widget. Analyze clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 14:09:11 -05:00
parent 342a8f8382
commit 6f1bfc753a
20 changed files with 876 additions and 36 deletions

View File

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