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:
87
lib/src/tiles/cached_tile_provider.dart
Normal file
87
lib/src/tiles/cached_tile_provider.dart
Normal file
@@ -0,0 +1,87 @@
|
||||
/// Feeds `RideMap`'s `TileLayer` from [TileCache] first, network second -- the same
|
||||
/// cache a V3-11 pre-download populates, so a rider who downloaded a corridor actually
|
||||
/// sees it render without a request going out. See V3-11.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'tile_cache.dart';
|
||||
import 'tile_math.dart';
|
||||
|
||||
class CachedTileProvider extends TileProvider {
|
||||
CachedTileProvider({required this.cache, http.Client? client})
|
||||
: _client = client ?? http.Client(),
|
||||
super();
|
||||
|
||||
final TileCache cache;
|
||||
final http.Client _client;
|
||||
|
||||
@override
|
||||
ImageProvider getImage(TileCoordinates coordinates, TileLayer options) =>
|
||||
_CacheBackedImage(
|
||||
key: TileKey(coordinates.z, coordinates.x, coordinates.y),
|
||||
url: getTileUrl(coordinates, options),
|
||||
headers: headers,
|
||||
cache: cache,
|
||||
client: _client,
|
||||
);
|
||||
}
|
||||
|
||||
class _CacheBackedImage extends ImageProvider<_CacheBackedImage> {
|
||||
const _CacheBackedImage({
|
||||
required this.key,
|
||||
required this.url,
|
||||
required this.headers,
|
||||
required this.cache,
|
||||
required this.client,
|
||||
});
|
||||
|
||||
final TileKey key;
|
||||
final String url;
|
||||
final Map<String, String> headers;
|
||||
final TileCache cache;
|
||||
final http.Client client;
|
||||
|
||||
@override
|
||||
Future<_CacheBackedImage> obtainKey(ImageConfiguration configuration) =>
|
||||
SynchronousFuture(this);
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(_CacheBackedImage key, ImageDecoderCallback decode) =>
|
||||
MultiFrameImageStreamCompleter(
|
||||
codec: _load(decode),
|
||||
scale: 1.0,
|
||||
debugLabel: url,
|
||||
);
|
||||
|
||||
Future<ui.Codec> _load(ImageDecoderCallback decode) async {
|
||||
final cached = await cache.get(key);
|
||||
final bytes = cached ?? await _fetchAndStore();
|
||||
final buffer = await ui.ImmutableBuffer.fromUint8List(bytes);
|
||||
return decode(buffer);
|
||||
}
|
||||
|
||||
Future<Uint8List> _fetchAndStore() async {
|
||||
final response = await client.get(Uri.parse(url), headers: headers);
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Tile fetch failed: ${response.statusCode} for $url');
|
||||
}
|
||||
final bytes = response.bodyBytes;
|
||||
// Write-through: viewing a tile online caches it for later, exactly like
|
||||
// flutter_map's own default caching did -- just capped and evictable now.
|
||||
await cache.put(key, bytes);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => other is _CacheBackedImage && other.key == key;
|
||||
|
||||
@override
|
||||
int get hashCode => key.hashCode;
|
||||
}
|
||||
139
lib/src/tiles/tile_cache.dart
Normal file
139
lib/src/tiles/tile_cache.dart
Normal file
@@ -0,0 +1,139 @@
|
||||
/// A persistent, size-capped tile cache. See V3-11.
|
||||
///
|
||||
/// **Storage growth is not optional to cap** (the ticket's own risk section): every
|
||||
/// [put] that would push the cache over [maxBytes] evicts the least-recently-used tiles
|
||||
/// first, until it fits, before the new tile is even written.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'tile_math.dart';
|
||||
|
||||
class _Entry {
|
||||
_Entry({required this.bytes, required this.lastAccess});
|
||||
|
||||
final int bytes;
|
||||
int lastAccess;
|
||||
}
|
||||
|
||||
abstract class TileCache {
|
||||
Future<void> put(TileKey key, Uint8List bytes);
|
||||
Future<Uint8List?> get(TileKey key);
|
||||
Future<int> sizeBytes();
|
||||
Future<void> clear();
|
||||
Future<void> dispose();
|
||||
}
|
||||
|
||||
/// Tiles as files on disk, keyed by `z_x_y`, with a JSON manifest tracking size and
|
||||
/// last-access time for LRU eviction. No database engine for what is, at the end of the
|
||||
/// day, a directory of small binary blobs with one number (last access) attached to each.
|
||||
class FileTileCache implements TileCache {
|
||||
FileTileCache({required Directory directory, required this.maxBytes})
|
||||
: _dir = directory;
|
||||
|
||||
final Directory _dir;
|
||||
final int maxBytes;
|
||||
|
||||
final _manifest = <String, _Entry>{};
|
||||
bool _loaded = false;
|
||||
int _clock = 0;
|
||||
|
||||
File get _manifestFile => File('${_dir.path}/manifest.json');
|
||||
File _tileFile(TileKey key) => File('${_dir.path}/${_fileName(key)}');
|
||||
String _fileName(TileKey key) => '${key.z}_${key.x}_${key.y}.tile';
|
||||
|
||||
Future<void> _ensureLoaded() async {
|
||||
if (_loaded) return;
|
||||
_loaded = true;
|
||||
if (!await _dir.exists()) await _dir.create(recursive: true);
|
||||
if (!await _manifestFile.exists()) return;
|
||||
final raw = jsonDecode(await _manifestFile.readAsString()) as Map<String, dynamic>;
|
||||
for (final entry in raw.entries) {
|
||||
final v = entry.value as Map<String, dynamic>;
|
||||
_manifest[entry.key] = _Entry(
|
||||
bytes: v['bytes'] as int,
|
||||
lastAccess: v['lastAccess'] as int,
|
||||
);
|
||||
_clock = _clock > (v['lastAccess'] as int) ? _clock : (v['lastAccess'] as int) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveManifest() => _manifestFile.writeAsString(
|
||||
jsonEncode({
|
||||
for (final e in _manifest.entries)
|
||||
e.key: {'bytes': e.value.bytes, 'lastAccess': e.value.lastAccess},
|
||||
}),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> put(TileKey key, Uint8List bytes) async {
|
||||
await _ensureLoaded();
|
||||
final name = _fileName(key);
|
||||
|
||||
// Replacing an existing tile: drop its old size first so the cap check below isn't
|
||||
// penalised by a tile that's about to be overwritten anyway.
|
||||
_manifest.remove(name);
|
||||
|
||||
if (_totalBytes() + bytes.length > maxBytes) {
|
||||
await _evictUntilFits(bytes.length);
|
||||
}
|
||||
|
||||
await _tileFile(key).writeAsBytes(bytes);
|
||||
_manifest[name] = _Entry(bytes: bytes.length, lastAccess: _clock++);
|
||||
await _saveManifest();
|
||||
}
|
||||
|
||||
Future<void> _evictUntilFits(int incomingBytes) async {
|
||||
// Oldest-accessed first.
|
||||
final byAge = _manifest.entries.toList()
|
||||
..sort((a, b) => a.value.lastAccess.compareTo(b.value.lastAccess));
|
||||
for (final entry in byAge) {
|
||||
if (_totalBytes() + incomingBytes <= maxBytes) break;
|
||||
_manifest.remove(entry.key);
|
||||
final f = File('${_dir.path}/${entry.key}');
|
||||
if (await f.exists()) await f.delete();
|
||||
}
|
||||
}
|
||||
|
||||
int _totalBytes() => _manifest.values.fold(0, (sum, e) => sum + e.bytes);
|
||||
|
||||
@override
|
||||
Future<Uint8List?> get(TileKey key) async {
|
||||
await _ensureLoaded();
|
||||
final name = _fileName(key);
|
||||
final entry = _manifest[name];
|
||||
if (entry == null) return null;
|
||||
final file = _tileFile(key);
|
||||
if (!await file.exists()) {
|
||||
// Manifest and disk disagree -- treat as a miss and self-heal the manifest rather
|
||||
// than surfacing an error for what is, from the caller's perspective, just an
|
||||
// uncached tile.
|
||||
_manifest.remove(name);
|
||||
return null;
|
||||
}
|
||||
entry.lastAccess = _clock++;
|
||||
return file.readAsBytes();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> sizeBytes() async {
|
||||
await _ensureLoaded();
|
||||
return _totalBytes();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clear() async {
|
||||
await _ensureLoaded();
|
||||
for (final key in _manifest.keys.toList()) {
|
||||
final f = File('${_dir.path}/$key');
|
||||
if (await f.exists()) await f.delete();
|
||||
}
|
||||
_manifest.clear();
|
||||
await _saveManifest();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {}
|
||||
}
|
||||
74
lib/src/tiles/tile_downloader.dart
Normal file
74
lib/src/tiles/tile_downloader.dart
Normal file
@@ -0,0 +1,74 @@
|
||||
/// Sequential, rate-limited, cancellable tile fetching. See V3-11.
|
||||
///
|
||||
/// **Never parallel, never a burst.** OSM's usage policy is what shapes this file --
|
||||
/// bulk/parallel fetching against the public tile servers is exactly what gets a user
|
||||
/// agent blocked, which would break the map for everyone, not just this download.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'tile_cache.dart';
|
||||
import 'tile_math.dart';
|
||||
|
||||
class DownloadProgress {
|
||||
const DownloadProgress({
|
||||
required this.completed,
|
||||
required this.total,
|
||||
required this.failed,
|
||||
});
|
||||
|
||||
final int completed;
|
||||
final int total;
|
||||
final int failed;
|
||||
|
||||
bool get isDone => completed + failed >= total;
|
||||
}
|
||||
|
||||
/// A cooperative cancel flag, checked between tiles -- not `Future.timeout` or a
|
||||
/// `Stream` subscription cancel, which would leave an in-flight fetch's result
|
||||
/// discarded rather than the loop simply stopping before starting the next one.
|
||||
class CancelToken {
|
||||
bool _cancelled = false;
|
||||
bool get isCancelled => _cancelled;
|
||||
void cancel() => _cancelled = true;
|
||||
}
|
||||
|
||||
/// Downloads [tiles] one at a time via [fetchTile], writing each to [cache] as it
|
||||
/// arrives -- so cancelling mid-download keeps everything already fetched, per the
|
||||
/// ticket's acceptance criteria, rather than committing only at the end.
|
||||
///
|
||||
/// [delay] is the rate limit: a pause after every tile, successful or not, so a large
|
||||
/// download reads as a slow trickle to the tile server rather than a burst.
|
||||
Stream<DownloadProgress> downloadTiles({
|
||||
required List<TileKey> tiles,
|
||||
required TileCache cache,
|
||||
required Future<Uint8List> Function(TileKey key) fetchTile,
|
||||
CancelToken? cancelToken,
|
||||
Duration delay = const Duration(milliseconds: 250),
|
||||
}) async* {
|
||||
var completed = 0;
|
||||
var failed = 0;
|
||||
final total = tiles.length;
|
||||
yield DownloadProgress(completed: 0, total: total, failed: 0);
|
||||
|
||||
for (final tile in tiles) {
|
||||
if (cancelToken?.isCancelled ?? false) break;
|
||||
|
||||
try {
|
||||
final bytes = await fetchTile(tile);
|
||||
await cache.put(tile, bytes);
|
||||
completed++;
|
||||
} on Object {
|
||||
// One bad tile (a transient network blip, a 404 at the map's edge) must not abort
|
||||
// tiles that would otherwise succeed -- the download is for a whole corridor, and
|
||||
// losing one tile in it is a much smaller problem than losing all of them.
|
||||
failed++;
|
||||
}
|
||||
|
||||
yield DownloadProgress(completed: completed, total: total, failed: failed);
|
||||
|
||||
if (cancelToken?.isCancelled ?? false) break;
|
||||
if (completed + failed < total) await Future<void>.delayed(delay);
|
||||
}
|
||||
}
|
||||
126
lib/src/tiles/tile_math.dart
Normal file
126
lib/src/tiles/tile_math.dart
Normal file
@@ -0,0 +1,126 @@
|
||||
/// Pure slippy-map tile math for V3-11's offline pre-download. No Flutter, no network --
|
||||
/// fully unit-testable, the same reasoning as `geo/geo.dart`.
|
||||
library;
|
||||
|
||||
import 'dart:math';
|
||||
|
||||
import '../geo/geo.dart' show LatLon;
|
||||
|
||||
/// One XYZ tile. Equality/hashCode so a `Set<TileKey>` can dedupe overlapping coverage
|
||||
/// from adjacent route points -- see [tilesAlongRoute].
|
||||
class TileKey {
|
||||
const TileKey(this.z, this.x, this.y);
|
||||
|
||||
final int z;
|
||||
final int x;
|
||||
final int y;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is TileKey && other.z == z && other.x == x && other.y == y;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(z, x, y);
|
||||
|
||||
@override
|
||||
String toString() => '$z/$x/$y';
|
||||
}
|
||||
|
||||
/// **Respect OSM's tile usage policy** (see the ticket): this is the hard ceiling on any
|
||||
/// single pre-download, regardless of how the caller arrived at a tile set. Enforced by
|
||||
/// [TooManyTilesException], not left to a caller to remember.
|
||||
const int maxTilesPerDownload = 2000;
|
||||
|
||||
class TooManyTilesException implements Exception {
|
||||
const TooManyTilesException(this.requested);
|
||||
|
||||
final int requested;
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'TooManyTilesException: $requested tiles requested, cap is $maxTilesPerDownload';
|
||||
}
|
||||
|
||||
int _lonToTileX(double lon, int z) =>
|
||||
(((lon + 180.0) / 360.0) * (1 << z)).floor().clamp(0, (1 << z) - 1);
|
||||
|
||||
int _latToTileY(double lat, int z) {
|
||||
final latRad = lat * pi / 180.0;
|
||||
final y =
|
||||
(1.0 - log(tan(latRad) + 1 / cos(latRad)) / pi) / 2.0 * (1 << z);
|
||||
return y.floor().clamp(0, (1 << z) - 1);
|
||||
}
|
||||
|
||||
/// Every tile covering a bounding box, across every zoom from [minZoom] to [maxZoom]
|
||||
/// inclusive. Throws [TooManyTilesException] rather than silently truncating -- a
|
||||
/// caller must shrink the area or the zoom range, not receive a partial download it
|
||||
/// doesn't know is partial.
|
||||
Set<TileKey> tilesForBounds({
|
||||
required double minLat,
|
||||
required double maxLat,
|
||||
required double minLon,
|
||||
required double maxLon,
|
||||
required int minZoom,
|
||||
required int maxZoom,
|
||||
}) {
|
||||
final tiles = <TileKey>{};
|
||||
for (var z = minZoom; z <= maxZoom; z++) {
|
||||
// Web Mercator y increases southward, so the northern (max) latitude gives the
|
||||
// smaller tile-y value.
|
||||
final minX = _lonToTileX(minLon, z);
|
||||
final maxX = _lonToTileX(maxLon, z);
|
||||
final minY = _latToTileY(maxLat, z);
|
||||
final maxY = _latToTileY(minLat, z);
|
||||
for (var x = minX; x <= maxX; x++) {
|
||||
for (var y = minY; y <= maxY; y++) {
|
||||
tiles.add(TileKey(z, x, y));
|
||||
if (tiles.length > maxTilesPerDownload) {
|
||||
throw TooManyTilesException(tiles.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return tiles;
|
||||
}
|
||||
|
||||
/// A tile corridor around a route rather than a rectangle around its bounding box -- "far
|
||||
/// fewer tiles for the same usefulness," as the ticket puts it. Buffers each point by
|
||||
/// [bufferMeters] and unions the small per-point tile sets, so a long thin route costs
|
||||
/// close to its actual length rather than the area of the box that contains it.
|
||||
Set<TileKey> tilesAlongRoute(
|
||||
List<LatLon> points, {
|
||||
required int minZoom,
|
||||
required int maxZoom,
|
||||
double bufferMeters = 300,
|
||||
}) {
|
||||
final tiles = <TileKey>{};
|
||||
// Rough conversion good enough for a small buffer: 1 degree of latitude is ~111.32 km
|
||||
// everywhere; longitude shrinks with cos(latitude), recomputed per point since a long
|
||||
// route can span enough latitude for that to matter.
|
||||
const metresPerDegreeLat = 111320.0;
|
||||
for (final p in points) {
|
||||
final dLat = bufferMeters / metresPerDegreeLat;
|
||||
final dLon = bufferMeters / (metresPerDegreeLat * cos(p.lat * pi / 180.0)).abs();
|
||||
tiles.addAll(
|
||||
tilesForBounds(
|
||||
minLat: p.lat - dLat,
|
||||
maxLat: p.lat + dLat,
|
||||
minLon: p.lon - dLon,
|
||||
maxLon: p.lon + dLon,
|
||||
minZoom: minZoom,
|
||||
maxZoom: maxZoom,
|
||||
),
|
||||
);
|
||||
if (tiles.length > maxTilesPerDownload) {
|
||||
throw TooManyTilesException(tiles.length);
|
||||
}
|
||||
}
|
||||
return tiles;
|
||||
}
|
||||
|
||||
/// A conservative estimate shown **before** any request goes out -- the acceptance
|
||||
/// criterion is a number the rider sees ahead of time, not an accurate one. OSM raster
|
||||
/// tiles are typically 10-25 KB; 15 KB is a reasonable middle estimate for a mixed
|
||||
/// urban/rural area.
|
||||
double estimatedSizeMb(int tileCount, {double avgTileSizeKb = 15}) =>
|
||||
tileCount * avgTileSizeKb / 1024;
|
||||
Reference in New Issue
Block a user