/// 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 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:http/http.dart' as http; 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 '../../tiles/tile_cache.dart'; import '../../tiles/tile_downloader.dart'; import '../../tiles/tile_math.dart'; import '../components/ride_map.dart' show maxTileZoom, tileUserAgent; import '../components/stats.dart' show confirmDialog; import '../format.dart'; class RoutePlannerScreen extends ConsumerStatefulWidget { const RoutePlannerScreen({super.key, required this.routeId, this.onBack}); final int routeId; final VoidCallback? onBack; @override ConsumerState createState() => _RoutePlannerScreenState(); } class _RoutePlannerScreenState extends ConsumerState { 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 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('download-tiles'), icon: const Icon(Icons.download_for_offline_outlined), tooltip: waypoints.isEmpty ? 'Add pins first' : 'Download offline tiles along this route', onPressed: waypoints.isEmpty ? null : () => _downloadOfflineTiles(context, waypoints), ), 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, tileProvider: ref.watch(cachedTileProviderProvider), ), 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 _rename( BuildContext context, RoutePlanRepository repo, RoutePlan route, ) async { final controller = TextEditingController(text: route.name); final name = await showDialog( 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); } /// V3-11: pre-downloads a corridor along the route, not a rectangle around it -- "far /// fewer tiles for the same usefulness," as the ticket puts it. Zoom range is fixed /// (city-street level through the map's own max) rather than picked by the rider -- /// keeping the choice small is part of what keeps this within OSM's usage policy. static const _downloadMinZoom = 13; Future _downloadOfflineTiles( BuildContext context, List waypoints, ) async { final Set tiles; try { tiles = tilesAlongRoute( [for (final w in waypoints) geo.LatLon(w.latitude, w.longitude)], minZoom: _downloadMinZoom, maxZoom: maxTileZoom.toInt(), ); } on TooManyTilesException catch (e) { if (!context.mounted) return; await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Too many tiles'), content: Text( 'This route would need ${e.requested} tiles, more than this app will ' 'request in one go. Try a shorter route.', ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('OK'), ), ], ), ); return; } final sizeMb = estimatedSizeMb(tiles.length); if (!context.mounted) return; final confirmed = await confirmDialog( context, title: 'Download ${tiles.length} tiles?', message: 'Roughly ${sizeMb.toStringAsFixed(1)} MB, fetched slowly and one at a ' 'time so the map stays available to everyone else using it too.', confirmLabel: 'Download', ); if (!confirmed) return; final cache = await ref.read(tileCacheProvider.future); if (!context.mounted) return; await showDialog( context: context, barrierDismissible: false, builder: (context) => _DownloadDialog(tiles: tiles.toList(), cache: cache), ); } } /// Owns the download's stream subscription for exactly the lifetime of the dialog -- /// pulled out of a plain `showDialog` builder because that builder re-runs on every /// `setState`, which would otherwise start a brand new overlapping download on every /// single progress update. class _DownloadDialog extends StatefulWidget { const _DownloadDialog({required this.tiles, required this.cache}); final List tiles; final TileCache cache; @override State<_DownloadDialog> createState() => _DownloadDialogState(); } class _DownloadDialogState extends State<_DownloadDialog> { final _token = CancelToken(); final _client = http.Client(); StreamSubscription? _sub; DownloadProgress _progress = const DownloadProgress(completed: 0, total: 0, failed: 0); @override void initState() { super.initState(); _sub = downloadTiles( tiles: widget.tiles, cache: widget.cache, cancelToken: _token, fetchTile: (key) async { final url = 'https://tile.openstreetmap.org/${key.z}/${key.x}/${key.y}.png'; final response = await _client.get( Uri.parse(url), headers: {'User-Agent': tileUserAgent}, ); if (response.statusCode != 200) { throw Exception('HTTP ${response.statusCode}'); } return response.bodyBytes; }, ).listen((p) { if (!mounted) return; setState(() => _progress = p); if (p.isDone) Navigator.of(context).pop(); }); } @override void dispose() { _sub?.cancel(); _client.close(); super.dispose(); } @override Widget build(BuildContext context) => AlertDialog( key: const Key('download-progress-dialog'), title: const Text('Downloading tiles'), content: Column( mainAxisSize: MainAxisSize.min, children: [ LinearProgressIndicator( value: _progress.total == 0 ? 0 : (_progress.completed + _progress.failed) / _progress.total, ), const SizedBox(height: 12), Text('${_progress.completed} of ${_progress.total} ' '(${_progress.failed} failed)'), ], ), actions: [ TextButton( key: const Key('cancel-download'), onPressed: () { _token.cancel(); Navigator.of(context).pop(); }, child: const Text('Cancel'), ), ], ); } 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, ), ), ), ); } }