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:
2026-08-17 19:45:40 -05:00
parent be50448917
commit e4ffafe9e0
16 changed files with 1089 additions and 5 deletions

View 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;
}