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:
@@ -14,6 +14,7 @@ import '../config/config.dart';
|
||||
import '../data/database.dart';
|
||||
import '../data/trip_repository.dart';
|
||||
import '../domain/models.dart';
|
||||
import '../data/route_plan_repository.dart';
|
||||
import '../notification/ride_notification_controller.dart';
|
||||
import '../notification/ride_notification_coordinator.dart';
|
||||
import '../recording/geolocator_location_source.dart';
|
||||
@@ -153,3 +154,21 @@ final livePointsProvider = StreamProvider.autoDispose.family<List<TrackPoint>, i
|
||||
final liveSegmentsProvider = StreamProvider.autoDispose.family<List<Segment>, int>(
|
||||
(ref, tripId) => ref.watch(databaseProvider).watchSegmentsForTrip(tripId),
|
||||
);
|
||||
|
||||
// --- Route plans (V3-07) -----------------------------------------------------
|
||||
|
||||
final routePlanRepositoryProvider = Provider<RoutePlanRepository>(
|
||||
(ref) => RoutePlanRepository(ref.watch(databaseProvider)),
|
||||
);
|
||||
|
||||
final routePlansProvider = StreamProvider<List<RoutePlan>>(
|
||||
(ref) => ref.watch(routePlanRepositoryProvider).watchRoutePlans(),
|
||||
);
|
||||
|
||||
final routePlanProvider = StreamProvider.autoDispose.family<RoutePlan?, int>(
|
||||
(ref, id) => ref.watch(routePlanRepositoryProvider).watchRoutePlan(id),
|
||||
);
|
||||
|
||||
final routeWaypointsProvider = StreamProvider.autoDispose.family<List<Waypoint>, int>(
|
||||
(ref, routeId) => ref.watch(routePlanRepositoryProvider).watchWaypoints(routeId),
|
||||
);
|
||||
|
||||
@@ -101,12 +101,52 @@ class TrackPoints extends Table {
|
||||
BoolColumn get synced => boolean().withDefault(const Constant(false))();
|
||||
}
|
||||
|
||||
@DriftDatabase(tables: [Trips, Segments, TrackPoints])
|
||||
/// A planned ride, sketched before setting off. See V3-07. Deliberately its own table,
|
||||
/// never joined into [Trips] or ride totals -- a plan is not a recording.
|
||||
@DataClassName('RoutePlanRow')
|
||||
class RoutePlans extends Table {
|
||||
@override
|
||||
String get tableName => 'route_plans';
|
||||
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
TextColumn get name => text()();
|
||||
IntColumn get createdAt => integer()();
|
||||
TextColumn get activity =>
|
||||
textEnum<domain.Activity>().withDefault(const Constant('motorcycle'))();
|
||||
RealColumn get distanceM => real().withDefault(const Constant(0))();
|
||||
|
||||
/// Null until V3-08 fills it in from a routing engine.
|
||||
IntColumn get estimatedMillis => integer().nullable()();
|
||||
|
||||
/// Null in this ticket -- the polyline is derived from waypoints, not stored. V3-08
|
||||
/// fills it with the road-snapped geometry, which is not cheaply re-derivable.
|
||||
TextColumn get geometry => text().nullable()();
|
||||
}
|
||||
|
||||
/// One pin on a [RoutePlans] row.
|
||||
@DataClassName('WaypointRow')
|
||||
@TableIndex(name: 'idx_waypoints_route', columns: {#routeId})
|
||||
class Waypoints extends Table {
|
||||
@override
|
||||
String get tableName => 'waypoints';
|
||||
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
IntColumn get routeId =>
|
||||
integer().references(RoutePlans, #id, onDelete: KeyAction.cascade)();
|
||||
|
||||
/// Position along the route, not insertion order -- see the domain doc comment.
|
||||
IntColumn get ordinal => integer()();
|
||||
RealColumn get latitude => real()();
|
||||
RealColumn get longitude => real()();
|
||||
TextColumn get name => text().nullable()();
|
||||
}
|
||||
|
||||
@DriftDatabase(tables: [Trips, Segments, TrackPoints, RoutePlans, Waypoints])
|
||||
class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase(super.e);
|
||||
|
||||
@override
|
||||
int get schemaVersion => 2;
|
||||
int get schemaVersion => 3;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
@@ -117,6 +157,11 @@ class AppDatabase extends _$AppDatabase {
|
||||
if (from < 2) {
|
||||
await m.addColumn(trips, trips.activity);
|
||||
}
|
||||
// V3-07: brand new tables, so a plain create is enough -- nothing to backfill.
|
||||
if (from < 3) {
|
||||
await m.createTable(routePlans);
|
||||
await m.createTable(waypoints);
|
||||
}
|
||||
},
|
||||
beforeOpen: (details) async {
|
||||
// Non-negotiable: without this the CASCADE relationships above do nothing.
|
||||
@@ -460,8 +505,111 @@ class AppDatabase extends _$AppDatabase {
|
||||
)..orderBy([(p) => OrderingTerm.asc(p.id)])).get();
|
||||
return rows.map(_toPoint).toList();
|
||||
}
|
||||
|
||||
// --- Route plans (V3-07) --------------------------------------------------
|
||||
// Deliberately separate from every trip query above: a plan never appears in ride
|
||||
// totals or the rides list.
|
||||
|
||||
Stream<List<domain.RoutePlan>> watchRoutePlans() =>
|
||||
(select(routePlans)..orderBy([(r) => OrderingTerm.desc(r.createdAt)]))
|
||||
.watch()
|
||||
.map((rows) => rows.map(_toRoutePlan).toList());
|
||||
|
||||
Stream<domain.RoutePlan?> watchRoutePlan(int id) =>
|
||||
(select(routePlans)..where((r) => r.id.equals(id)))
|
||||
.watchSingleOrNull()
|
||||
.map((r) => r == null ? null : _toRoutePlan(r));
|
||||
|
||||
Future<domain.RoutePlan?> getRoutePlan(int id) async {
|
||||
final row = await (select(
|
||||
routePlans,
|
||||
)..where((r) => r.id.equals(id))).getSingleOrNull();
|
||||
return row == null ? null : _toRoutePlan(row);
|
||||
}
|
||||
|
||||
Future<int> insertRoutePlan(domain.RoutePlan route) => into(routePlans).insert(
|
||||
RoutePlansCompanion.insert(
|
||||
name: route.name,
|
||||
createdAt: route.createdAt,
|
||||
activity: Value(route.activity),
|
||||
distanceM: Value(route.distanceM),
|
||||
estimatedMillis: Value(route.estimatedMillis),
|
||||
geometry: Value(route.geometry),
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> renameRoutePlan(int id, String name) => (update(
|
||||
routePlans,
|
||||
)..where((r) => r.id.equals(id))).write(RoutePlansCompanion(name: Value(name)));
|
||||
|
||||
Future<void> setRoutePlanDistance(int id, double distanceM) => (update(
|
||||
routePlans,
|
||||
)..where((r) => r.id.equals(id))).write(
|
||||
RoutePlansCompanion(distanceM: Value(distanceM)),
|
||||
);
|
||||
|
||||
/// Waypoints cascade with it.
|
||||
Future<void> deleteRoutePlan(int id) =>
|
||||
(delete(routePlans)..where((r) => r.id.equals(id))).go();
|
||||
|
||||
Stream<List<domain.Waypoint>> watchWaypoints(int routeId) =>
|
||||
(select(waypoints)
|
||||
..where((w) => w.routeId.equals(routeId))
|
||||
..orderBy([(w) => OrderingTerm.asc(w.ordinal)]))
|
||||
.watch()
|
||||
.map((rows) => rows.map(_toWaypoint).toList());
|
||||
|
||||
Future<List<domain.Waypoint>> waypointsForRoute(int routeId) async {
|
||||
final rows =
|
||||
await (select(waypoints)
|
||||
..where((w) => w.routeId.equals(routeId))
|
||||
..orderBy([(w) => OrderingTerm.asc(w.ordinal)]))
|
||||
.get();
|
||||
return rows.map(_toWaypoint).toList();
|
||||
}
|
||||
|
||||
Future<int> insertWaypoint(domain.Waypoint waypoint) => into(waypoints).insert(
|
||||
WaypointsCompanion.insert(
|
||||
routeId: waypoint.routeId,
|
||||
ordinal: waypoint.ordinal,
|
||||
latitude: waypoint.latitude,
|
||||
longitude: waypoint.longitude,
|
||||
name: Value(waypoint.name),
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> moveWaypoint(int id, double latitude, double longitude) =>
|
||||
(update(waypoints)..where((w) => w.id.equals(id))).write(
|
||||
WaypointsCompanion(latitude: Value(latitude), longitude: Value(longitude)),
|
||||
);
|
||||
|
||||
Future<void> setWaypointOrdinal(int id, int ordinal) => (update(
|
||||
waypoints,
|
||||
)..where((w) => w.id.equals(id))).write(WaypointsCompanion(ordinal: Value(ordinal)));
|
||||
|
||||
Future<void> deleteWaypoint(int id) =>
|
||||
(delete(waypoints)..where((w) => w.id.equals(id))).go();
|
||||
}
|
||||
|
||||
domain.RoutePlan _toRoutePlan(RoutePlanRow r) => domain.RoutePlan(
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
createdAt: r.createdAt,
|
||||
activity: r.activity,
|
||||
distanceM: r.distanceM,
|
||||
estimatedMillis: r.estimatedMillis,
|
||||
geometry: r.geometry,
|
||||
);
|
||||
|
||||
domain.Waypoint _toWaypoint(WaypointRow r) => domain.Waypoint(
|
||||
id: r.id,
|
||||
routeId: r.routeId,
|
||||
ordinal: r.ordinal,
|
||||
latitude: r.latitude,
|
||||
longitude: r.longitude,
|
||||
name: r.name,
|
||||
);
|
||||
|
||||
// --- Row → domain mapping ---------------------------------------------------
|
||||
// Kept as free functions rather than extension getters so the domain layer stays
|
||||
// entirely unaware that Drift exists.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
102
lib/src/data/route_plan_repository.dart
Normal file
102
lib/src/data/route_plan_repository.dart
Normal file
@@ -0,0 +1,102 @@
|
||||
/// Owns route-plan lifecycle: create, edit waypoints, rename, delete. See V3-07.
|
||||
///
|
||||
/// Mirrors [TripRepository]'s shape deliberately, but a route plan has no state machine
|
||||
/// to speak of -- there is no equivalent of "recording"; every write here is a direct,
|
||||
/// synchronous-feeling edit to a thing the rider is actively sketching.
|
||||
library;
|
||||
|
||||
import '../domain/models.dart';
|
||||
import '../geo/geo.dart' as geo;
|
||||
import 'database.dart';
|
||||
|
||||
class RoutePlanRepository {
|
||||
RoutePlanRepository(this._db);
|
||||
|
||||
final AppDatabase _db;
|
||||
|
||||
Stream<List<RoutePlan>> watchRoutePlans() => _db.watchRoutePlans();
|
||||
|
||||
Stream<RoutePlan?> watchRoutePlan(int id) => _db.watchRoutePlan(id);
|
||||
|
||||
Future<RoutePlan?> routePlanById(int id) => _db.getRoutePlan(id);
|
||||
|
||||
Stream<List<Waypoint>> watchWaypoints(int routeId) => _db.watchWaypoints(routeId);
|
||||
|
||||
Future<List<Waypoint>> waypointsFor(int routeId) => _db.waypointsForRoute(routeId);
|
||||
|
||||
/// A fresh, unnamed plan with no pins yet.
|
||||
Future<int> createRoutePlan(int now, {String? name, Activity activity = Activity.motorcycle}) =>
|
||||
_db.insertRoutePlan(
|
||||
RoutePlan(name: name ?? 'New route', createdAt: now, activity: activity),
|
||||
);
|
||||
|
||||
Future<void> renameRoutePlan(int id, String name) => _db.renameRoutePlan(id, name);
|
||||
|
||||
/// Waypoints cascade with it.
|
||||
Future<void> deleteRoutePlan(int id) => _db.deleteRoutePlan(id);
|
||||
|
||||
/// Appends a pin at the end of the route and recomputes the straight-line distance.
|
||||
Future<void> addWaypoint(int routeId, double latitude, double longitude) async {
|
||||
final existing = await _db.waypointsForRoute(routeId);
|
||||
await _db.insertWaypoint(
|
||||
Waypoint(
|
||||
routeId: routeId,
|
||||
ordinal: existing.length,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
),
|
||||
);
|
||||
await _recomputeDistance(routeId);
|
||||
}
|
||||
|
||||
Future<void> moveWaypoint(int routeId, int waypointId, double latitude, double longitude) async {
|
||||
await _db.moveWaypoint(waypointId, latitude, longitude);
|
||||
await _recomputeDistance(routeId);
|
||||
}
|
||||
|
||||
Future<void> deleteWaypoint(int routeId, int waypointId) async {
|
||||
await _db.deleteWaypoint(waypointId);
|
||||
// Ordinals must stay contiguous, or a reorder later would leave a gap that ordering
|
||||
// by ordinal alone cannot express.
|
||||
final remaining = await _db.waypointsForRoute(routeId);
|
||||
for (var i = 0; i < remaining.length; i++) {
|
||||
if (remaining[i].ordinal != i) {
|
||||
await _db.setWaypointOrdinal(remaining[i].id, i);
|
||||
}
|
||||
}
|
||||
await _recomputeDistance(routeId);
|
||||
}
|
||||
|
||||
/// Moves the waypoint at [fromIndex] to [toIndex] and renumbers everything between them.
|
||||
Future<void> reorderWaypoint(int routeId, int fromIndex, int toIndex) async {
|
||||
final waypoints = await _db.waypointsForRoute(routeId);
|
||||
if (fromIndex == toIndex ||
|
||||
fromIndex < 0 ||
|
||||
fromIndex >= waypoints.length ||
|
||||
toIndex < 0 ||
|
||||
toIndex >= waypoints.length) {
|
||||
return;
|
||||
}
|
||||
final reordered = List<Waypoint>.of(waypoints);
|
||||
final moved = reordered.removeAt(fromIndex);
|
||||
reordered.insert(toIndex, moved);
|
||||
for (var i = 0; i < reordered.length; i++) {
|
||||
if (reordered[i].ordinal != i) {
|
||||
await _db.setWaypointOrdinal(reordered[i].id, i);
|
||||
}
|
||||
}
|
||||
// Pure reordering does not change distance, but keeping this call here (rather than
|
||||
// asserting the caller never needs it) means the invariant "distanceM always matches
|
||||
// the current waypoints" holds after every mutating call, with no exceptions to
|
||||
// remember.
|
||||
await _recomputeDistance(routeId);
|
||||
}
|
||||
|
||||
Future<void> _recomputeDistance(int routeId) async {
|
||||
final waypoints = await _db.waypointsForRoute(routeId);
|
||||
final distance = geo.pathLengthMeters([
|
||||
for (final w in waypoints) geo.LatLon(w.latitude, w.longitude),
|
||||
]);
|
||||
await _db.setRoutePlanDistance(routeId, distance);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,10 +38,16 @@ class RecordUiState {
|
||||
}
|
||||
|
||||
class RecordScreen extends ConsumerStatefulWidget {
|
||||
const RecordScreen({super.key, this.onOpenTrips, this.onOpenSettings});
|
||||
const RecordScreen({
|
||||
super.key,
|
||||
this.onOpenTrips,
|
||||
this.onOpenSettings,
|
||||
this.onOpenRoutes,
|
||||
});
|
||||
|
||||
final VoidCallback? onOpenTrips;
|
||||
final VoidCallback? onOpenSettings;
|
||||
final VoidCallback? onOpenRoutes;
|
||||
|
||||
@override
|
||||
ConsumerState<RecordScreen> createState() => _RecordScreenState();
|
||||
@@ -198,6 +204,11 @@ class _RecordScreenState extends ConsumerState<RecordScreen> {
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
TextButton(
|
||||
key: const Key('open-routes'),
|
||||
onPressed: widget.onOpenRoutes,
|
||||
child: const Text('Routes'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: widget.onOpenTrips,
|
||||
child: const Text('Rides'),
|
||||
|
||||
@@ -10,6 +10,8 @@ import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'detail/trip_detail_screen.dart';
|
||||
import 'record/record_screen.dart';
|
||||
import 'routes/route_planner_screen.dart';
|
||||
import 'routes/routes_list_screen.dart';
|
||||
import 'settings/settings_screen.dart';
|
||||
import 'trips/trips_screen.dart';
|
||||
|
||||
@@ -18,8 +20,11 @@ abstract final class Routes {
|
||||
static const trips = '/trips';
|
||||
static const tripDetail = '/trip/:tripId';
|
||||
static const settings = '/settings';
|
||||
static const routes = '/routes';
|
||||
static const routePlanner = '/routes/:routeId';
|
||||
|
||||
static String detailFor(int tripId) => '/trip/$tripId';
|
||||
static String plannerFor(int routeId) => '/routes/$routeId';
|
||||
}
|
||||
|
||||
GoRouter buildRouter() => GoRouter(
|
||||
@@ -30,8 +35,24 @@ GoRouter buildRouter() => GoRouter(
|
||||
builder: (context, state) => RecordScreen(
|
||||
onOpenTrips: () => context.push(Routes.trips),
|
||||
onOpenSettings: () => context.push(Routes.settings),
|
||||
onOpenRoutes: () => context.push(Routes.routes),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.routes,
|
||||
builder: (context, state) => RoutesListScreen(
|
||||
onOpenRoute: (id) => context.push(Routes.plannerFor(id)),
|
||||
onBack: () => context.pop(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.routePlanner,
|
||||
builder: (context, state) {
|
||||
final id = int.tryParse(state.pathParameters['routeId'] ?? '');
|
||||
if (id == null) return const _NotFound();
|
||||
return RoutePlannerScreen(routeId: id, onBack: () => context.pop());
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.settings,
|
||||
builder: (context, state) =>
|
||||
|
||||
268
lib/src/ui/routes/route_planner_screen.dart
Normal file
268
lib/src/ui/routes/route_planner_screen.dart
Normal file
@@ -0,0 +1,268 @@
|
||||
/// V3-07: tap the map to drop a pin, drag a pin to move it, tap a pin to delete it.
|
||||
///
|
||||
/// Straight lines only, deliberately — see the ticket. `geometry` stays null; the
|
||||
/// polyline drawn here is derived from waypoints on every build, never persisted.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:latlong2/latlong.dart' as ll;
|
||||
|
||||
import '../../app/providers.dart';
|
||||
import '../../data/route_plan_repository.dart';
|
||||
import '../../domain/models.dart';
|
||||
import '../../geo/geo.dart' as geo;
|
||||
import '../components/ride_map.dart' show maxTileZoom, tileUserAgent;
|
||||
import '../format.dart';
|
||||
|
||||
class RoutePlannerScreen extends ConsumerStatefulWidget {
|
||||
const RoutePlannerScreen({super.key, required this.routeId, this.onBack});
|
||||
|
||||
final int routeId;
|
||||
final VoidCallback? onBack;
|
||||
|
||||
@override
|
||||
ConsumerState<RoutePlannerScreen> createState() => _RoutePlannerScreenState();
|
||||
}
|
||||
|
||||
class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||
final _mapController = MapController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_mapController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Fits every pin in view, the same way `RideMap` fits a recorded path -- otherwise a
|
||||
/// fixed zoom guess either strands distant pins off-screen or, for two close-together
|
||||
/// pins, can cull one before it is ever seen. Null below two points: a single pin (or
|
||||
/// none) has no meaningful bounds to fit.
|
||||
CameraFit? _initialFit(List<Waypoint> waypoints) {
|
||||
if (waypoints.length < 2) return null;
|
||||
final bounds = geo.bounds([
|
||||
for (final w in waypoints) geo.LatLon(w.latitude, w.longitude),
|
||||
])!;
|
||||
if (bounds.isDegenerate) return null;
|
||||
return CameraFit.bounds(
|
||||
bounds: LatLngBounds(
|
||||
ll.LatLng(bounds.minLat, bounds.minLon),
|
||||
ll.LatLng(bounds.maxLat, bounds.maxLon),
|
||||
),
|
||||
padding: const EdgeInsets.all(48),
|
||||
maxZoom: maxTileZoom,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final route = ref.watch(routePlanProvider(widget.routeId)).valueOrNull;
|
||||
final waypointsAsync = ref.watch(routeWaypointsProvider(widget.routeId));
|
||||
final waypoints = waypointsAsync.valueOrNull ?? const [];
|
||||
final units = ref.watch(unitSystemProvider);
|
||||
final repo = ref.read(routePlanRepositoryProvider);
|
||||
|
||||
if (route == null) {
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: Text(
|
||||
'This route no longer exists.',
|
||||
style: TextStyle(color: colors.outline),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
key: const Key('back'),
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: widget.onBack,
|
||||
),
|
||||
title: Text(route.name),
|
||||
actions: [
|
||||
IconButton(
|
||||
key: const Key('rename-route'),
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
onPressed: () => _rename(context, repo, route),
|
||||
),
|
||||
IconButton(
|
||||
key: const Key('delete-route'),
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
onPressed: () async {
|
||||
await repo.deleteRoutePlan(widget.routeId);
|
||||
widget.onBack?.call();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
// `MapOptions.initialCenter`/`initialZoom` are read exactly once, at
|
||||
// FlutterMap's construction -- not on every rebuild. Building the map before
|
||||
// the waypoints stream has delivered its first value would freeze the camera
|
||||
// on null-island forever, even once real waypoints arrive. Wait for the first
|
||||
// emission (typically one frame) so the initial camera is right from the
|
||||
// start.
|
||||
child: !waypointsAsync.hasValue
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: FlutterMap(
|
||||
mapController: _mapController,
|
||||
options: MapOptions(
|
||||
initialCameraFit: _initialFit(waypoints),
|
||||
initialCenter: waypoints.isEmpty
|
||||
? const ll.LatLng(0, 0)
|
||||
: ll.LatLng(waypoints.first.latitude, waypoints.first.longitude),
|
||||
initialZoom: waypoints.length <= 1 ? 14 : maxTileZoom - 3,
|
||||
maxZoom: maxTileZoom,
|
||||
onTap: (tapPosition, point) =>
|
||||
repo.addWaypoint(widget.routeId, point.latitude, point.longitude),
|
||||
),
|
||||
children: [
|
||||
TileLayer(
|
||||
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName: tileUserAgent,
|
||||
maxNativeZoom: maxTileZoom.toInt(),
|
||||
panBuffer: 0,
|
||||
),
|
||||
if (waypoints.length >= 2)
|
||||
PolylineLayer(
|
||||
polylines: [
|
||||
Polyline(
|
||||
points: [
|
||||
for (final w in waypoints) ll.LatLng(w.latitude, w.longitude),
|
||||
],
|
||||
strokeWidth: 4,
|
||||
// Visibly distinct from a recorded path (see the acceptance
|
||||
// criteria) — dashed, and the planning accent rather than the
|
||||
// speed-bucketed colours RideMap uses.
|
||||
pattern: StrokePattern.dashed(segments: const [8, 6]),
|
||||
color: colors.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
MarkerLayer(
|
||||
markers: [
|
||||
for (var i = 0; i < waypoints.length; i++)
|
||||
Marker(
|
||||
key: Key('waypoint-${waypoints[i].id}'),
|
||||
point: ll.LatLng(waypoints[i].latitude, waypoints[i].longitude),
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: _WaypointPin(
|
||||
index: i,
|
||||
onTap: () =>
|
||||
repo.deleteWaypoint(widget.routeId, waypoints[i].id),
|
||||
onPanUpdate: (delta) {
|
||||
final camera = _mapController.camera;
|
||||
final current = ll.LatLng(
|
||||
waypoints[i].latitude,
|
||||
waypoints[i].longitude,
|
||||
);
|
||||
final newOffset =
|
||||
camera.latLngToScreenOffset(current) + delta;
|
||||
final moved = camera.screenOffsetToLatLng(newOffset);
|
||||
repo.moveWaypoint(
|
||||
widget.routeId,
|
||||
waypoints[i].id,
|
||||
moved.latitude,
|
||||
moved.longitude,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.straighten, size: 18, color: colors.outline),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
key: const Key('route-distance'),
|
||||
formatDistance(route.distanceM, unit: units),
|
||||
style: TextStyle(fontWeight: FontWeight.bold, color: colors.onSurface),
|
||||
),
|
||||
Text(
|
||||
' · ${waypoints.length} pin${waypoints.length == 1 ? '' : 's'}',
|
||||
style: TextStyle(color: colors.outline),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _rename(
|
||||
BuildContext context,
|
||||
RoutePlanRepository repo,
|
||||
RoutePlan route,
|
||||
) async {
|
||||
final controller = TextEditingController(text: route.name);
|
||||
final name = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Rename route'),
|
||||
content: TextField(key: const Key('route-name-field'), controller: controller),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(controller.text.trim()),
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (name == null || name.isEmpty) return;
|
||||
await repo.renameRoutePlan(widget.routeId, name);
|
||||
}
|
||||
}
|
||||
|
||||
class _WaypointPin extends StatelessWidget {
|
||||
const _WaypointPin({
|
||||
required this.index,
|
||||
required this.onTap,
|
||||
required this.onPanUpdate,
|
||||
});
|
||||
|
||||
final int index;
|
||||
final VoidCallback onTap;
|
||||
final void Function(Offset delta) onPanUpdate;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
onPanUpdate: (details) => onPanUpdate(details.delta),
|
||||
child: CircleAvatar(
|
||||
backgroundColor: colors.primary,
|
||||
radius: 14,
|
||||
child: Text(
|
||||
'${index + 1}',
|
||||
style: TextStyle(
|
||||
color: colors.onPrimary,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
91
lib/src/ui/routes/routes_list_screen.dart
Normal file
91
lib/src/ui/routes/routes_list_screen.dart
Normal file
@@ -0,0 +1,91 @@
|
||||
/// V3-07: routes never appear in the rides list, and never touch a `Trip` row.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../app/providers.dart';
|
||||
import '../../domain/models.dart';
|
||||
import '../components/stats.dart';
|
||||
import '../format.dart';
|
||||
|
||||
class RoutesListScreen extends ConsumerWidget {
|
||||
const RoutesListScreen({super.key, this.onOpenRoute, this.onBack});
|
||||
|
||||
final void Function(int routeId)? onOpenRoute;
|
||||
final VoidCallback? onBack;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final routes = ref.watch(routePlansProvider).valueOrNull ?? const <RoutePlan>[];
|
||||
final units = ref.watch(unitSystemProvider);
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
TextButton(
|
||||
key: const Key('back'),
|
||||
onPressed: onBack,
|
||||
child: const Text('‹ Record'),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 8),
|
||||
child: Text('ROUTES', style: TextStyle(letterSpacing: 2)),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
key: const Key('new-route'),
|
||||
icon: const Icon(Icons.add),
|
||||
tooltip: 'New route',
|
||||
onPressed: () async {
|
||||
final id = await ref
|
||||
.read(routePlanRepositoryProvider)
|
||||
.createRoutePlan(DateTime.now().millisecondsSinceEpoch);
|
||||
onOpenRoute?.call(id);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: routes.isEmpty
|
||||
? const EmptyState(
|
||||
message: 'No routes yet.\nDrop pins on a map to plan a ride.',
|
||||
icon: Icons.route,
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: routes.length,
|
||||
itemBuilder: (context, i) {
|
||||
final route = routes[i];
|
||||
return Card(
|
||||
key: Key('route-${route.id}'),
|
||||
child: ListTile(
|
||||
title: Text(route.name),
|
||||
subtitle: Text(formatDistance(route.distanceM, unit: units)),
|
||||
trailing: IconButton(
|
||||
key: Key('delete-route-${route.id}'),
|
||||
icon: Icon(Icons.delete_outline, color: colors.error),
|
||||
onPressed: () => ref
|
||||
.read(routePlanRepositoryProvider)
|
||||
.deleteRoutePlan(route.id),
|
||||
),
|
||||
onTap: () => onOpenRoute?.call(route.id),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user