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

@@ -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'),

View File

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

View 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,
),
),
),
);
}
}

View 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),
),
);
},
),
),
],
),
),
);
}
}