Three pure modules: tile_math.dart (tilesForBounds/tilesAlongRoute, hard cap enforced by throwing), tile_cache.dart (FileTileCache with LRU eviction before any write that would exceed the cap), tile_downloader.dart (sequential, rate-limited, cooperatively cancellable, one bad tile doesn't abort the rest). CachedTileProvider wires the cache into flutter_map via a custom ImageProvider and now backs RideMap and RoutePlannerScreen's tile layers, so ordinary viewing write-throughs into the same capped cache. RoutePlannerScreen gained a route-corridor download action (no rectangle-selection UI -- the ticket names the corridor as strictly better and V3-07 already exists to hang it off of), with a count/size confirmation before any request and a cancellable progress dialog. Fixed a real bug before shipping: a StatefulBuilder-based progress dialog would have started a new overlapping download subscription on every single progress tick; moved to a dedicated StatefulWidget that subscribes once in initState. Marked partially done: aeroplane-mode verification on a real device is the ticket's own acceptance criterion and needs a phone this environment doesn't have.
427 lines
14 KiB
Dart
427 lines
14 KiB
Dart
/// 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<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('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<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);
|
|
}
|
|
|
|
/// 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<void> _downloadOfflineTiles(
|
|
BuildContext context,
|
|
List<Waypoint> waypoints,
|
|
) async {
|
|
final Set<TileKey> 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<void>(
|
|
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<void>(
|
|
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<TileKey> tiles;
|
|
final TileCache cache;
|
|
|
|
@override
|
|
State<_DownloadDialog> createState() => _DownloadDialogState();
|
|
}
|
|
|
|
class _DownloadDialogState extends State<_DownloadDialog> {
|
|
final _token = CancelToken();
|
|
final _client = http.Client();
|
|
StreamSubscription<DownloadProgress>? _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,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|