Route plans are their own entity (RoutePlan/Waypoint, schema v3), never joined into ride totals or the rides list. RoutePlanRepository recomputes straight-line distance after every waypoint mutation. RoutePlannerScreen supports tap-to-add, tap-to-delete, and hand-rolled drag-to-move via MapCamera's screen/latlng conversions. Fixed a real bug found by testing: FlutterMap's initialCenter/initialZoom are read once at construction, so building the map before the waypoints stream's first emission froze the camera on null-island. Also documents a genuine ten-minute test hang traced to Stream.first on a Drift watch() query inside testWidgets, which needs a pump-driven Timer that flutter_test's fake zone never advances on its own.
5.7 KiB
V3-07 — Route drawing (pins and straight lines)
Phase Route planning · Depends on nothing · Size M · Status Done
Goal
Drop pins on a map to sketch a route, see the straight-line distance, and save it. The foundation for V3-08, deliberately shipped without a routing engine.
Context
Pre-ride planning is a genuinely new mode, not an extension of recording. It needs no ride in progress, no server, and nothing else in v3 — the most independent thing in the backlog.
Split from road-snapped routing (V3-08) on purpose: pins, storage, editing and the map interaction are all needed either way, and none of them require choosing a routing vendor. That decision should not block a usable feature.
Design
New entities, separate from Trip. A plan is not a recording, and conflating them
would put unridden kilometres into ride totals.
Route(id, name, createdAt, activity?, distanceM, estimatedMillis?, geometry?)
Waypoint(id, routeId, ordinal, latitude, longitude, name?)
geometry is null in this ticket; V3-08 fills it with the road-snapped polyline.
ordinal rather than relying on insertion id, so waypoints can be reordered.
Straight-line distance reuses haversineMeters — already ported and parity-proven.
Implementation
- Drift tables + migration (the second one; V3-01 proves the path)
RouteRepositorymirroringTripRepository's shapeRoutePlannerScreen— tap to add a pin, drag to move, tap a pin to delete, reorder- Straight-line polyline between pins, visibly distinct from a recorded path
- Routes list, reachable from the record screen alongside Rides
- Name, rename, delete
Acceptance criteria
- Pins can be added, moved, reordered and deleted
- Distance updates live as pins change
- A saved route survives an app restart
- Routes never appear in the rides list, and never contribute to ride totals
- Deleting a route cascades to its waypoints
Tests
- Repository: create, reorder, delete, cascade — in-memory, like the trip tests
- Distance matches
pathLengthMetersover the same points - Widget: tapping the map adds a pin; the distance label updates
- A test asserting routes are absent from
watchCompletedTrips
Risks
The main one is scope drift into V3-08. Ship straight lines first; they are genuinely useful for a rough plan.
Out of scope
Road snapping, ETA, following a route while riding. Import of existing GPX routes.
Outcome
Shipped as designed, with one naming deviation and two real testing traps worth recording for V3-08/V3-09.
Named RoutePlan, not Route. The ticket's own design sketch used Route, but that
collides with dart:ui/package:flutter's own Route<T> (the navigator's page-transition
class) and with go_router's GoRoute. Renaming up front avoided constant hide/as
import juggling across every file that touches both navigation and route plans.
Schema: route_plans/waypoints tables, schema version 2→3, following V3-01's migration
pattern exactly (m.createTable for brand-new tables needs no backfill, unlike V3-01's
addColumn). RoutePlanRepository mirrors TripRepository's shape but has no state
machine — every mutating call ends by recomputing distanceM via geo.pathLengthMeters,
so "distance always matches the current waypoints" holds with no exceptions to remember,
including after a pure reorder that doesn't change it.
RoutePlannerScreen: tap-to-add via MapOptions.onTap, tap-a-pin-to-delete, and
drag-to-move implemented by hand against MapCamera.latLngToScreenOffset/
screenOffsetToLatLng (flutter_map has no built-in draggable-marker widget). The straight
line is dashed and uses the planning accent, visibly distinct from RideMap's
speed-bucketed solid polyline, satisfying the acceptance criterion without a design pass.
Real bug found by testing, not review: the map's initialCenter/initialZoom are
read exactly once, at FlutterMap construction. Building the map before the waypoints
stream delivered its first value froze the camera on null-island permanently, even once
real waypoints arrived — invisible in manual testing (a route sketched from empty always
starts empty) but immediate in a test that opens a planner for a route with existing
waypoints. Fixed by gating the map behind the stream's first emission, and by switching
from a fixed-zoom guess to CameraFit.bounds (matching RideMap's own established
pattern) so pins can't be culled off-camera either.
Real testing trap, likely to recur in V3-08/V3-09: await db.watchRoutePlans().first
inside a testWidgets body hung for a genuine ten minutes (the framework's own internal
timeout, not a guess) — a fresh Stream.first subscription on a Drift .watch() query
depends on a Timer inside Drift's stream-query store that flutter_test's fake test zone
never advances without an explicit pump. repo-level Future-returning calls
(routePlanById, etc.) have no such dependency and are what every other assertion in this
suite already used correctly. Documented inline in the test as a trap for the next ticket
that watches a stream from inside testWidgets.
16 new tests: 8 in route_plan_repository_test.dart (create, live distance on
add/move/delete, ordinal-gap closing, reordering, rename, cascade delete, and the
ticket-mandated "never appears in ride totals" check), 1 migration test (v2→v3, tables
created and usable, existing trip untouched), 7 in route_planner_screen_test.dart
(empty state, create-and-open, delete, tap-to-add-and-distance-updates, tap-to-delete,
rename, missing-route fallback), plus 1 in widget_test.dart for the Routes entry point
on the record screen. flutter analyze clean; full suite green (254 tests, up from 237).