Files
rippr/docs/v3/V3-01-activity-type.md
uhryniuk 6f1bfc753a 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>
2026-08-17 14:09:11 -05:00

4.7 KiB

V3-01 — Activity type per ride

Phase Foundations · Depends on nothing · Size M · Status Done

Goal

Every ride records what it was done on — motorcycle, bicycle, scooter, skateboard, running, walking, other — and that choice drives per-activity defaults rather than just labelling the row.

Context

The recording pipeline never cared what you were sitting on: it records positions, speeds and altitudes. Only the UI says "motorcycle". Dylan rides both bikes and motorcycles.

This is also a positioning decision (see ../LAUNCH.md): it determines the store category, the screenshots, and who ever finds the app. Cyclists are a much larger audience and already pay for ride apps. Cheaper to settle before a store listing exists.

This ticket ships the port's first real migration. That matters more than the feature.

Design

Text enum column on Trip, exactly like TripState: motorcycle · bicycle · scooter · skateboard · running · walking · other

Type drives defaults, not labels. Introduce an ActivityProfile rather than scattering if (activity == …):

Setting Today Why it must vary
Speed noise floor 1.5 km/h Right for a motorcycle; walking lives near it
Histogram bucket 10 km/h Useless for running — one bucket. Wants ~1 km/h
Accuracy gate 50 m A bike at speed tolerates looser fixes than a walker
Elevation smoothing window 15 samples Tuned for 2 Hz at road speed
Map fit zoom — A 2 km walk and a 200 km ride differ

No picker in front of Start. The founding premise is press-and-go with gloves on. Default to the last activity used; make it editable on trip detail next to rename.

Implementation

  1. Activity enum in domain/models.dart; activity field on Trip
  2. Drift column with .withDefault(Constant('motorcycle'))
  3. Migration: schemaVersion 1 → 2, m.addColumn(trips, trips.activity)
  4. ActivityProfile in stats/ holding the constants above; thread it through computeSummary, speedHistogram, Accumulator, isUsableFix
  5. Persist last-used activity in Config
  6. Trip detail: activity row, editable via the same pattern as rename
  7. Trips list: show the activity icon on each tile

Acceptance criteria

  • A new ride records an activity; existing rides read motorcycle
  • A v1 database opens, migrates, and keeps every ride and point
  • Changing a trip's activity recomputes its aggregates under the new profile
  • Start still takes exactly one tap
  • flutter analyze clean, all existing tests still pass

Tests

  • Migration test — build a v1 database, migrate, assert rides and points survive. Drift's MigrationTestHelper with a generated v1 schema.
  • Profile selection: each activity yields its own noise floor and bucket size
  • A running-activity ride produces a histogram with more than one bucket
  • Round-trip the enum through the database

Risks

  • The migration is the risk. Getting it wrong destroys real rides, and the destructive fallback is deliberately gone. Write the migration test first.
  • Recomputing aggregates on activity change is easy to forget — a ride switched from motorcycle to walking keeps a wrong moving time otherwise.

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).