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:
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user