V3-11: offline tile pre-download along a route corridor
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.
This commit is contained in:
@@ -4,16 +4,23 @@
|
||||
/// 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 {
|
||||
@@ -86,6 +93,16 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||
),
|
||||
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),
|
||||
@@ -130,6 +147,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||
userAgentPackageName: tileUserAgent,
|
||||
maxNativeZoom: maxTileZoom.toInt(),
|
||||
panBuffer: 0,
|
||||
tileProvider: ref.watch(cachedTileProviderProvider),
|
||||
),
|
||||
if (waypoints.length >= 2)
|
||||
PolylineLayer(
|
||||
@@ -232,6 +250,146 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user