V3-07: route drawing (pins and straight lines)

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.
This commit is contained in:
2026-08-17 19:15:18 -05:00
parent 5013b7002f
commit a14f5b0134
15 changed files with 2694 additions and 5 deletions

View File

@@ -225,3 +225,86 @@ class RideStats {
int get durationMillis =>
pointCount == 0 ? 0 : lastTimestamp - firstTimestamp;
}
/// A planned ride, sketched before setting off. See V3-07 in `docs/v3/`.
///
/// Deliberately **not** a [Trip] and never joined into ride totals or the rides list — a
/// plan is not a recording, and conflating them would put unridden kilometres into ride
/// totals. [distanceM] is the straight-line sum between waypoints in this ticket;
/// [geometry] and [estimatedMillis] stay null until V3-08 adds road-snapped routing.
class RoutePlan {
const RoutePlan({
this.id = 0,
required this.name,
required this.createdAt,
this.activity = Activity.motorcycle,
this.distanceM = 0.0,
this.estimatedMillis,
this.geometry,
});
final int id;
final String name;
final int createdAt;
final Activity activity;
final double distanceM;
/// Null until V3-08 fills it in from a routing engine.
final int? estimatedMillis;
/// Null until V3-08. This ticket's polyline is drawn straight between waypoints and
/// never stored -- it is fully derived from them, so there is nothing to persist.
final String? geometry;
RoutePlan copyWith({
int? id,
String? name,
int? createdAt,
Activity? activity,
double? distanceM,
int? estimatedMillis,
String? geometry,
}) => RoutePlan(
id: id ?? this.id,
name: name ?? this.name,
createdAt: createdAt ?? this.createdAt,
activity: activity ?? this.activity,
distanceM: distanceM ?? this.distanceM,
estimatedMillis: estimatedMillis ?? this.estimatedMillis,
geometry: geometry ?? this.geometry,
);
}
/// One pin on a [RoutePlan].
///
/// Ordered by [ordinal] rather than insertion id, so a pin can be dragged into a new
/// position without touching every other row's identity.
class Waypoint {
const Waypoint({
this.id = 0,
required this.routeId,
required this.ordinal,
required this.latitude,
required this.longitude,
this.name,
});
final int id;
final int routeId;
final int ordinal;
final double latitude;
final double longitude;
/// Null means the UI derives a label from [ordinal] ("Point 3"). Never store an empty
/// string -- the same rule as [Trip.name].
final String? name;
Waypoint copyWith({int? id, int? routeId, int? ordinal}) => Waypoint(
id: id ?? this.id,
routeId: routeId ?? this.routeId,
ordinal: ordinal ?? this.ordinal,
latitude: latitude,
longitude: longitude,
name: name,
);
}