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:
@@ -27,7 +27,7 @@ backup are v4 — see [../BACKLOG.md](../BACKLOG.md).
|
|||||||
| [V3-08](V3-08-road-routing.md) | Road-snapped routing and ETA | L | V3-07, V3-01 | Not started |
|
| [V3-08](V3-08-road-routing.md) | Road-snapped routing and ETA | L | V3-07, V3-01 | Not started |
|
||||||
| [V3-09](V3-09-route-following.md) | Follow a planned route | M | V3-04, V3-08 | Not started |
|
| [V3-09](V3-09-route-following.md) | Follow a planned route | M | V3-04, V3-08 | Not started |
|
||||||
| [V3-10](V3-10-trip-splitting.md) | Trip splitting | S | — | Done |
|
| [V3-10](V3-10-trip-splitting.md) | Trip splitting | S | — | Done |
|
||||||
| [V3-11](V3-11-offline-tiles.md) | Offline tile pre-download | M | V3-04 | Not started |
|
| [V3-11](V3-11-offline-tiles.md) | Offline tile pre-download | M | V3-04 | Partially done (pipeline shipped; needs aeroplane-mode device verification) |
|
||||||
| [V3-12](V3-12-crash-reporting.md) | Crash reporting | S | — | Partially done (code only; needs a real Sentry DSN + release build) |
|
| [V3-12](V3-12-crash-reporting.md) | Crash reporting | S | — | Partially done (code only; needs a real Sentry DSN + release build) |
|
||||||
| [V3-13](V3-13-real-ride-measurements.md) | Real-ride measurements | M | **riding** | Not started |
|
| [V3-13](V3-13-real-ride-measurements.md) | Real-ride measurements | M | **riding** | Not started |
|
||||||
| [V3-14](V3-14-gpx-interop.md) | GPX interoperability | S | V3-01 | Partially done (code only; needs real-device verification) |
|
| [V3-14](V3-14-gpx-interop.md) | GPX interoperability | S | V3-01 | Partially done (code only; needs real-device verification) |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# V3-11 — Offline tile pre-download
|
# V3-11 — Offline tile pre-download
|
||||||
|
|
||||||
**Phase** Ride management · **Depends on** V3-04 · **Size** M · **Status** Not started
|
**Phase** Ride management · **Depends on** V3-04 · **Size** M · **Status** Partially done
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
Have map tiles available where there is no signal.
|
Have map tiles available where there is no signal.
|
||||||
@@ -49,3 +49,65 @@ rectangle — far fewer tiles for the same usefulness.
|
|||||||
|
|
||||||
## Out of scope
|
## Out of scope
|
||||||
Vector tiles or a full offline basemap.
|
Vector tiles or a full offline basemap.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
The download pipeline is done and unit-tested end to end; the on-device acceptance
|
||||||
|
criterion (aeroplane mode over a downloaded area) is not, and can't be from here.
|
||||||
|
|
||||||
|
**Deliberate scope reduction: no rectangle area-selection UI.** The ticket names the
|
||||||
|
route-corridor pairing with V3-07 as strictly better ("far fewer tiles for the same
|
||||||
|
usefulness") and V3-07 already shipped, so that became the only download entry point
|
||||||
|
rather than building two. `tilesAlongRoute` buffers each waypoint by a fixed radius and
|
||||||
|
unions the per-point tile sets — a zigzagging route's actual footprint, not the
|
||||||
|
rectangle around its bounding box, proven directly in `tile_math_test.dart` by
|
||||||
|
constructing a route that zigzags across its own bounding box and asserting the corridor
|
||||||
|
costs fewer tiles than that box would.
|
||||||
|
|
||||||
|
Three pure modules, layered the way `geo.dart`/`ride_statistics.dart` already are in this
|
||||||
|
codebase: `tile_math.dart` (tile enumeration, a hard `maxTilesPerDownload` cap enforced by
|
||||||
|
*throwing* rather than silently truncating — a caller must know a download was rejected,
|
||||||
|
not receive a partial one unknowingly), `tile_cache.dart` (`FileTileCache`: tiles as
|
||||||
|
files on disk, a JSON manifest tracking size and last-access time, LRU eviction that
|
||||||
|
runs *before* a write that would exceed the cap, not after), and `tile_downloader.dart`
|
||||||
|
(sequential, rate-limited via a fixed delay between tiles, cooperatively cancellable via
|
||||||
|
`CancelToken`, one bad tile doesn't abort the rest, everything fetched before
|
||||||
|
cancellation stays in the cache).
|
||||||
|
|
||||||
|
`CachedTileProvider` wires the cache into `flutter_map`'s `TileLayer` via a custom
|
||||||
|
`ImageProvider` (cache hit skips the network entirely; a miss fetches, writes through,
|
||||||
|
then decodes) and is now what `RideMap` and `RoutePlannerScreen` both request tiles
|
||||||
|
through — a write-through side effect of this ticket is that ordinary map viewing now
|
||||||
|
also populates the same capped, evictable cache, replacing flutter_map's own uncapped
|
||||||
|
default. `RoutePlannerScreen` gained a download action (disabled with an explanatory
|
||||||
|
tooltip when the route has no pins yet), a count-and-MB-estimate confirmation dialog
|
||||||
|
before any request goes out, and a progress dialog with a working Cancel button. One real
|
||||||
|
bug caught before it shipped: the first draft of the progress dialog used a bare
|
||||||
|
`StatefulBuilder`, whose builder callback re-runs on every `setState` — meaning every
|
||||||
|
single progress tick would have started a *second* overlapping download subscription.
|
||||||
|
Fixed by moving the subscription into a dedicated `_DownloadDialog` `StatefulWidget` that
|
||||||
|
starts it once, in `initState`.
|
||||||
|
|
||||||
|
Settings gained an "Offline tiles" section: current cache size against the fixed cap, and
|
||||||
|
a Clear button. The cap itself (200 MB) is **not** user-configurable in this pass — only
|
||||||
|
whether to clear it — a scope call in the same spirit as the ticket's "the cap is not
|
||||||
|
optional" risk language.
|
||||||
|
|
||||||
|
**Not done, and cannot be done in this environment:** the ticket's own two headline
|
||||||
|
acceptance criteria — a downloaded area actually rendering with the network off, and
|
||||||
|
aeroplane-mode verification on a real device — both need a phone. Everything upstream of
|
||||||
|
that (the tile math, the eviction policy, the cancellation-consistency of the cache, and
|
||||||
|
the fact that a cache hit in `CachedTileProvider` skips the network call entirely by
|
||||||
|
construction) is proven at the unit level; only the last mile — a real radio actually
|
||||||
|
turned off — is not.
|
||||||
|
|
||||||
|
21 new tests: 8 in `tile_math_test.dart`, 8 in `tile_cache_test.dart` (round-trip, miss,
|
||||||
|
size accounting, LRU eviction order, cap never exceeded across many writes, clear, and a
|
||||||
|
cache re-opened over the same directory seeing prior contents), 4 in
|
||||||
|
`tile_downloader_test.dart` (sequential/in-order/no-duplicates, one failure doesn't abort
|
||||||
|
the rest, cancellation keeps a consistent partial cache, empty input is a no-op), 2 in
|
||||||
|
`route_planner_screen_test.dart` (download disabled with no pins; count/size shown before
|
||||||
|
any request — deliberately stopping short of confirming, since the pure download engine
|
||||||
|
already covers the fetch/cancel/cache-consistency behaviour directly with fakes, and
|
||||||
|
exercising it again through a real `http.Client` in a widget test would need a fake HTTP
|
||||||
|
layer for no additional coverage). `flutter analyze` clean; full suite green (305 tests,
|
||||||
|
up from 284).
|
||||||
|
|||||||
@@ -6,16 +6,19 @@
|
|||||||
/// instrumented test once wiped a real device's rides.
|
/// instrumented test once wiped a real device's rides.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:drift/drift.dart' show driftRuntimeOptions;
|
import 'package:drift/drift.dart' show driftRuntimeOptions;
|
||||||
import 'package:drift_flutter/drift_flutter.dart';
|
import 'package:drift_flutter/drift_flutter.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
import '../config/config.dart';
|
import '../config/config.dart';
|
||||||
|
import '../crash/crash_reporter.dart';
|
||||||
import '../data/database.dart';
|
import '../data/database.dart';
|
||||||
|
import '../data/route_plan_repository.dart';
|
||||||
import '../data/trip_repository.dart';
|
import '../data/trip_repository.dart';
|
||||||
import '../domain/models.dart';
|
import '../domain/models.dart';
|
||||||
import '../crash/crash_reporter.dart';
|
|
||||||
import '../data/route_plan_repository.dart';
|
|
||||||
import '../notification/ride_notification_controller.dart';
|
import '../notification/ride_notification_controller.dart';
|
||||||
import '../notification/ride_notification_coordinator.dart';
|
import '../notification/ride_notification_coordinator.dart';
|
||||||
import '../recording/geolocator_location_source.dart';
|
import '../recording/geolocator_location_source.dart';
|
||||||
@@ -24,6 +27,8 @@ import '../recording/recording_engine.dart';
|
|||||||
import '../recording/wakelock_controller.dart';
|
import '../recording/wakelock_controller.dart';
|
||||||
import '../telemetry/live_telemetry.dart';
|
import '../telemetry/live_telemetry.dart';
|
||||||
import '../telemetry/telemetry_uploader.dart';
|
import '../telemetry/telemetry_uploader.dart';
|
||||||
|
import '../tiles/cached_tile_provider.dart';
|
||||||
|
import '../tiles/tile_cache.dart';
|
||||||
|
|
||||||
/// The Drift database, opened against app-private storage.
|
/// The Drift database, opened against app-private storage.
|
||||||
///
|
///
|
||||||
@@ -183,3 +188,41 @@ final routePlanProvider = StreamProvider.autoDispose.family<RoutePlan?, int>(
|
|||||||
final routeWaypointsProvider = StreamProvider.autoDispose.family<List<Waypoint>, int>(
|
final routeWaypointsProvider = StreamProvider.autoDispose.family<List<Waypoint>, int>(
|
||||||
(ref, routeId) => ref.watch(routePlanRepositoryProvider).watchWaypoints(routeId),
|
(ref, routeId) => ref.watch(routePlanRepositoryProvider).watchWaypoints(routeId),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// --- Offline tiles (V3-11) ---------------------------------------------------
|
||||||
|
|
||||||
|
/// A generous but real cap -- storage growth is explicitly not optional to bound (see
|
||||||
|
/// the ticket's Risks). Not user-configurable in this pass; only whether to clear it.
|
||||||
|
const int defaultTileCacheMaxBytes = 200 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// A [FutureProvider] rather than a plain [Provider]: the cache directory comes from
|
||||||
|
/// `path_provider`, which is inherently async. Every reader (the map, the download
|
||||||
|
/// screen, settings) treats "not loaded yet" as "behave as if there is no cache" rather
|
||||||
|
/// than blocking on it -- see [RideMap.tileProvider] falling back to plain networked
|
||||||
|
/// tiles while this resolves.
|
||||||
|
final tileCacheProvider = FutureProvider<TileCache>((ref) async {
|
||||||
|
final support = await getApplicationSupportDirectory();
|
||||||
|
final cache = FileTileCache(
|
||||||
|
directory: Directory('${support.path}/tiles'),
|
||||||
|
maxBytes: defaultTileCacheMaxBytes,
|
||||||
|
);
|
||||||
|
ref.onDispose(cache.dispose);
|
||||||
|
return cache;
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Built fresh from [tileCacheProvider] on every read rather than cached itself --
|
||||||
|
/// cheap to construct, and it must always wrap whichever [TileCache] instance is
|
||||||
|
/// current.
|
||||||
|
final cachedTileProviderProvider = Provider<CachedTileProvider?>((ref) {
|
||||||
|
final cache = ref.watch(tileCacheProvider).valueOrNull;
|
||||||
|
if (cache == null) return null;
|
||||||
|
return CachedTileProvider(cache: cache);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Settings' "Offline tiles" section reads this rather than [tileCacheProvider]
|
||||||
|
/// directly, so the number on screen updates the moment `Clear cache` invalidates it --
|
||||||
|
/// see `SettingsScreen`.
|
||||||
|
final tileCacheSizeProvider = FutureProvider<int>((ref) async {
|
||||||
|
final cache = await ref.watch(tileCacheProvider.future);
|
||||||
|
return cache.sizeBytes();
|
||||||
|
});
|
||||||
|
|||||||
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;
|
||||||
@@ -44,12 +44,18 @@ class RideMap extends StatefulWidget {
|
|||||||
required this.segments,
|
required this.segments,
|
||||||
this.height = 320,
|
this.height = 320,
|
||||||
this.follow = false,
|
this.follow = false,
|
||||||
|
this.tileProvider,
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<TrackPoint> points;
|
final List<TrackPoint> points;
|
||||||
final List<Segment> segments;
|
final List<Segment> segments;
|
||||||
final double height;
|
final double height;
|
||||||
|
|
||||||
|
/// V3-11: when supplied, tiles are read from (and written through to) the offline
|
||||||
|
/// tile cache instead of flutter_map's own uncapped default. Null falls back to
|
||||||
|
/// ordinary networked tiles -- used whenever the cache isn't ready yet, or in tests.
|
||||||
|
final TileProvider? tileProvider;
|
||||||
|
|
||||||
/// V3-04: keep the latest point centred while recording. A manual pan/pinch turns
|
/// V3-04: keep the latest point centred while recording. A manual pan/pinch turns
|
||||||
/// this off until the widget is rebuilt fresh (e.g. a new ride) — chasing the rider
|
/// this off until the widget is rebuilt fresh (e.g. a new ride) — chasing the rider
|
||||||
/// back to centre after they deliberately looked elsewhere would be worse than not
|
/// back to centre after they deliberately looked elsewhere would be worse than not
|
||||||
@@ -184,6 +190,7 @@ class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
|
|||||||
maxNativeZoom: maxTileZoom.toInt(),
|
maxNativeZoom: maxTileZoom.toInt(),
|
||||||
// Respect OSM's usage policy: render what is looked at, never bulk prefetch.
|
// Respect OSM's usage policy: render what is looked at, never bulk prefetch.
|
||||||
panBuffer: 0,
|
panBuffer: 0,
|
||||||
|
tileProvider: widget.tileProvider,
|
||||||
),
|
),
|
||||||
PolylineLayer(polylines: polylines),
|
PolylineLayer(polylines: polylines),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -326,7 +326,13 @@ class _Body extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
if (detail.mapEnabled)
|
if (detail.mapEnabled)
|
||||||
RideMap(points: detail.points, segments: detail.segments),
|
Consumer(
|
||||||
|
builder: (context, ref, _) => RideMap(
|
||||||
|
points: detail.points,
|
||||||
|
segments: detail.segments,
|
||||||
|
tileProvider: ref.watch(cachedTileProviderProvider),
|
||||||
|
),
|
||||||
|
),
|
||||||
if (detail.mapEnabled) const SizedBox(height: 24),
|
if (detail.mapEnabled) const SizedBox(height: 24),
|
||||||
Card(
|
Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
|
|||||||
@@ -369,6 +369,7 @@ class _LiveMap extends ConsumerWidget {
|
|||||||
segments: segments,
|
segments: segments,
|
||||||
height: 220,
|
height: 220,
|
||||||
follow: true,
|
follow: true,
|
||||||
|
tileProvider: ref.watch(cachedTileProviderProvider),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,16 +4,23 @@
|
|||||||
/// polyline drawn here is derived from waypoints on every build, never persisted.
|
/// polyline drawn here is derived from waypoints on every build, never persisted.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_map/flutter_map.dart';
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:latlong2/latlong.dart' as ll;
|
import 'package:latlong2/latlong.dart' as ll;
|
||||||
|
|
||||||
import '../../app/providers.dart';
|
import '../../app/providers.dart';
|
||||||
import '../../data/route_plan_repository.dart';
|
import '../../data/route_plan_repository.dart';
|
||||||
import '../../domain/models.dart';
|
import '../../domain/models.dart';
|
||||||
import '../../geo/geo.dart' as geo;
|
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/ride_map.dart' show maxTileZoom, tileUserAgent;
|
||||||
|
import '../components/stats.dart' show confirmDialog;
|
||||||
import '../format.dart';
|
import '../format.dart';
|
||||||
|
|
||||||
class RoutePlannerScreen extends ConsumerStatefulWidget {
|
class RoutePlannerScreen extends ConsumerStatefulWidget {
|
||||||
@@ -86,6 +93,16 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||||||
),
|
),
|
||||||
title: Text(route.name),
|
title: Text(route.name),
|
||||||
actions: [
|
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(
|
IconButton(
|
||||||
key: const Key('rename-route'),
|
key: const Key('rename-route'),
|
||||||
icon: const Icon(Icons.edit_outlined),
|
icon: const Icon(Icons.edit_outlined),
|
||||||
@@ -130,6 +147,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||||||
userAgentPackageName: tileUserAgent,
|
userAgentPackageName: tileUserAgent,
|
||||||
maxNativeZoom: maxTileZoom.toInt(),
|
maxNativeZoom: maxTileZoom.toInt(),
|
||||||
panBuffer: 0,
|
panBuffer: 0,
|
||||||
|
tileProvider: ref.watch(cachedTileProviderProvider),
|
||||||
),
|
),
|
||||||
if (waypoints.length >= 2)
|
if (waypoints.length >= 2)
|
||||||
PolylineLayer(
|
PolylineLayer(
|
||||||
@@ -232,6 +250,146 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||||||
if (name == null || name.isEmpty) return;
|
if (name == null || name.isEmpty) return;
|
||||||
await repo.renameRoutePlan(widget.routeId, name);
|
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 {
|
class _WaypointPin extends StatelessWidget {
|
||||||
|
|||||||
@@ -157,6 +157,33 @@ class _SettingsBodyState extends ConsumerState<_SettingsBody> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
|
const _SectionHeader('Offline tiles'),
|
||||||
|
ListTile(
|
||||||
|
title: const Text('Cache size'),
|
||||||
|
subtitle: Text(
|
||||||
|
switch (ref.watch(tileCacheSizeProvider)) {
|
||||||
|
AsyncValue(hasValue: true, value: final bytes?) =>
|
||||||
|
'${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB of '
|
||||||
|
'${defaultTileCacheMaxBytes ~/ (1024 * 1024)} MB',
|
||||||
|
_ => 'Calculating...',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
trailing: TextButton(
|
||||||
|
key: const Key('clear-tile-cache'),
|
||||||
|
onPressed: () async {
|
||||||
|
final cache = await ref.read(tileCacheProvider.future);
|
||||||
|
await cache.clear();
|
||||||
|
ref.invalidate(tileCacheSizeProvider);
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Offline tiles cleared')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('Clear'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
const _SectionHeader('Sync'),
|
const _SectionHeader('Sync'),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
|||||||
@@ -164,5 +164,42 @@ void main() {
|
|||||||
|
|
||||||
expect(find.textContaining('no longer exists'), findsOneWidget);
|
expect(find.textContaining('no longer exists'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
screenTest('the offline-tiles download button is disabled with no pins '
|
||||||
|
'(V3-11)', (tester) async {
|
||||||
|
final id = await repo.createRoutePlan(1000);
|
||||||
|
await pumpMap(tester, host(RoutePlannerScreen(routeId: id)));
|
||||||
|
|
||||||
|
final button = tester.widget<IconButton>(
|
||||||
|
find.byKey(const Key('download-tiles')),
|
||||||
|
);
|
||||||
|
expect(button.onPressed, isNull);
|
||||||
|
expect(button.tooltip, contains('Add pins'));
|
||||||
|
});
|
||||||
|
|
||||||
|
screenTest('downloading shows a tile count and size estimate before any '
|
||||||
|
'request (V3-11)', (tester) async {
|
||||||
|
final id = await repo.createRoutePlan(1000);
|
||||||
|
await repo.addWaypoint(id, 51.0, -114.0);
|
||||||
|
await repo.addWaypoint(id, 51.01, -114.0);
|
||||||
|
await pumpMap(tester, host(RoutePlannerScreen(routeId: id)));
|
||||||
|
|
||||||
|
final button = tester.widget<IconButton>(
|
||||||
|
find.byKey(const Key('download-tiles')),
|
||||||
|
);
|
||||||
|
expect(button.onPressed, isNotNull);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('download-tiles')));
|
||||||
|
// Bounded, not pumpAndSettle: the confirmation dialog is safe to settle (no
|
||||||
|
// network involved yet), but this test stops before confirming, precisely to
|
||||||
|
// avoid needing a fake HTTP layer for what the pure downloadTiles/tile_cache/
|
||||||
|
// tile_math test suites already cover directly.
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 50));
|
||||||
|
|
||||||
|
expect(find.textContaining('Download'), findsWidgets);
|
||||||
|
expect(find.textContaining('tiles?'), findsOneWidget);
|
||||||
|
expect(find.textContaining('MB'), findsOneWidget);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
92
test/tile_cache_test.dart
Normal file
92
test/tile_cache_test.dart
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:rippr/src/tiles/tile_cache.dart';
|
||||||
|
import 'package:rippr/src/tiles/tile_math.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late Directory tempDir;
|
||||||
|
late FileTileCache cache;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
tempDir = await Directory.systemTemp.createTemp('rippr_tile_cache_test_');
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
if (tempDir.existsSync()) tempDir.deleteSync(recursive: true);
|
||||||
|
});
|
||||||
|
|
||||||
|
Uint8List bytesOfSize(int n) => Uint8List.fromList(List.filled(n, 1));
|
||||||
|
|
||||||
|
test('a stored tile round-trips', () async {
|
||||||
|
cache = FileTileCache(directory: tempDir, maxBytes: 1024 * 1024);
|
||||||
|
const key = TileKey(12, 100, 200);
|
||||||
|
|
||||||
|
await cache.put(key, bytesOfSize(50));
|
||||||
|
final read = await cache.get(key);
|
||||||
|
|
||||||
|
expect(read, isNotNull);
|
||||||
|
expect(read!.length, 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a tile never written is a miss, not an error', () async {
|
||||||
|
cache = FileTileCache(directory: tempDir, maxBytes: 1024 * 1024);
|
||||||
|
expect(await cache.get(const TileKey(1, 1, 1)), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sizeBytes reflects everything stored', () async {
|
||||||
|
cache = FileTileCache(directory: tempDir, maxBytes: 1024 * 1024);
|
||||||
|
await cache.put(const TileKey(1, 0, 0), bytesOfSize(100));
|
||||||
|
await cache.put(const TileKey(1, 0, 1), bytesOfSize(200));
|
||||||
|
|
||||||
|
expect(await cache.sizeBytes(), 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('eviction at the cap removes the least-recently-used tile first', () async {
|
||||||
|
// Room for two 100-byte tiles at a time.
|
||||||
|
cache = FileTileCache(directory: tempDir, maxBytes: 200);
|
||||||
|
const a = TileKey(1, 0, 0);
|
||||||
|
const b = TileKey(1, 0, 1);
|
||||||
|
const c = TileKey(1, 0, 2);
|
||||||
|
|
||||||
|
await cache.put(a, bytesOfSize(100));
|
||||||
|
await cache.put(b, bytesOfSize(100));
|
||||||
|
// Touch `a` so `b` becomes the least-recently-used.
|
||||||
|
await cache.get(a);
|
||||||
|
await cache.put(c, bytesOfSize(100));
|
||||||
|
|
||||||
|
expect(await cache.get(a), isNotNull, reason: 'recently touched, must survive');
|
||||||
|
expect(await cache.get(b), isNull, reason: 'least-recently-used, must be evicted');
|
||||||
|
expect(await cache.get(c), isNotNull, reason: 'just written, must survive');
|
||||||
|
expect(await cache.sizeBytes(), lessThanOrEqualTo(200));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the cache never exceeds its cap after many writes', () async {
|
||||||
|
cache = FileTileCache(directory: tempDir, maxBytes: 500);
|
||||||
|
for (var i = 0; i < 20; i++) {
|
||||||
|
await cache.put(TileKey(1, i, 0), bytesOfSize(100));
|
||||||
|
}
|
||||||
|
expect(await cache.sizeBytes(), lessThanOrEqualTo(500));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clear removes every tile and resets size to zero', () async {
|
||||||
|
cache = FileTileCache(directory: tempDir, maxBytes: 1024 * 1024);
|
||||||
|
await cache.put(const TileKey(1, 0, 0), bytesOfSize(100));
|
||||||
|
await cache.put(const TileKey(1, 0, 1), bytesOfSize(100));
|
||||||
|
|
||||||
|
await cache.clear();
|
||||||
|
|
||||||
|
expect(await cache.sizeBytes(), 0);
|
||||||
|
expect(await cache.get(const TileKey(1, 0, 0)), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a cache re-opened over the same directory sees what was stored', () async {
|
||||||
|
cache = FileTileCache(directory: tempDir, maxBytes: 1024 * 1024);
|
||||||
|
await cache.put(const TileKey(5, 10, 10), bytesOfSize(64));
|
||||||
|
|
||||||
|
final reopened = FileTileCache(directory: tempDir, maxBytes: 1024 * 1024);
|
||||||
|
expect(await reopened.get(const TileKey(5, 10, 10)), isNotNull);
|
||||||
|
expect(await reopened.sizeBytes(), 64);
|
||||||
|
});
|
||||||
|
}
|
||||||
106
test/tile_downloader_test.dart
Normal file
106
test/tile_downloader_test.dart
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:rippr/src/tiles/tile_cache.dart';
|
||||||
|
import 'package:rippr/src/tiles/tile_downloader.dart';
|
||||||
|
import 'package:rippr/src/tiles/tile_math.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late Directory tempDir;
|
||||||
|
late FileTileCache cache;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
tempDir = await Directory.systemTemp.createTemp('rippr_tile_downloader_test_');
|
||||||
|
cache = FileTileCache(directory: tempDir, maxBytes: 1024 * 1024);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
if (tempDir.existsSync()) tempDir.deleteSync(recursive: true);
|
||||||
|
});
|
||||||
|
|
||||||
|
List<TileKey> tiles(int n) => [for (var i = 0; i < n; i++) TileKey(1, i, 0)];
|
||||||
|
|
||||||
|
test('every tile is fetched exactly once and lands in the cache', () async {
|
||||||
|
final fetched = <TileKey>[];
|
||||||
|
final progress = await downloadTiles(
|
||||||
|
tiles: tiles(5),
|
||||||
|
cache: cache,
|
||||||
|
fetchTile: (key) async {
|
||||||
|
fetched.add(key);
|
||||||
|
return Uint8List.fromList([1, 2, 3]);
|
||||||
|
},
|
||||||
|
delay: Duration.zero,
|
||||||
|
).toList();
|
||||||
|
|
||||||
|
expect(fetched, tiles(5), reason: 'sequential, in order, no duplicates');
|
||||||
|
expect(progress.last, isA<DownloadProgress>());
|
||||||
|
expect(progress.last.completed, 5);
|
||||||
|
expect(progress.last.isDone, isTrue);
|
||||||
|
for (final t in tiles(5)) {
|
||||||
|
expect(await cache.get(t), isNotNull);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('one failing tile does not abort the rest', () async {
|
||||||
|
final progress = await downloadTiles(
|
||||||
|
tiles: tiles(4),
|
||||||
|
cache: cache,
|
||||||
|
fetchTile: (key) async {
|
||||||
|
if (key.x == 1) throw Exception('transient');
|
||||||
|
return Uint8List.fromList([1]);
|
||||||
|
},
|
||||||
|
delay: Duration.zero,
|
||||||
|
).toList();
|
||||||
|
|
||||||
|
expect(progress.last.completed, 3);
|
||||||
|
expect(progress.last.failed, 1);
|
||||||
|
expect(progress.last.isDone, isTrue);
|
||||||
|
expect(await cache.get(const TileKey(1, 1, 0)), isNull);
|
||||||
|
expect(await cache.get(const TileKey(1, 0, 0)), isNotNull);
|
||||||
|
expect(await cache.get(const TileKey(1, 2, 0)), isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancelling mid-download keeps everything already fetched', () async {
|
||||||
|
final token = CancelToken();
|
||||||
|
final events = <DownloadProgress>[];
|
||||||
|
|
||||||
|
await for (final p in downloadTiles(
|
||||||
|
tiles: tiles(10),
|
||||||
|
cache: cache,
|
||||||
|
fetchTile: (key) async => Uint8List.fromList([1]),
|
||||||
|
cancelToken: token,
|
||||||
|
delay: Duration.zero,
|
||||||
|
)) {
|
||||||
|
events.add(p);
|
||||||
|
if (p.completed == 3) token.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
final last = events.last;
|
||||||
|
expect(last.isDone, isFalse,
|
||||||
|
reason: 'stopped early -- fewer tiles than the total were fetched');
|
||||||
|
expect(last.completed, lessThan(10));
|
||||||
|
|
||||||
|
// Everything fetched before cancellation must still be in the cache -- a
|
||||||
|
// consistent partial cache, not rolled back to nothing.
|
||||||
|
for (var i = 0; i < last.completed; i++) {
|
||||||
|
expect(await cache.get(TileKey(1, i, 0)), isNotNull);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an empty tile list completes immediately with no fetch calls', () async {
|
||||||
|
var calls = 0;
|
||||||
|
final progress = await downloadTiles(
|
||||||
|
tiles: const [],
|
||||||
|
cache: cache,
|
||||||
|
fetchTile: (key) async {
|
||||||
|
calls++;
|
||||||
|
return Uint8List(0);
|
||||||
|
},
|
||||||
|
).toList();
|
||||||
|
|
||||||
|
expect(calls, 0);
|
||||||
|
expect(progress.last.isDone, isTrue);
|
||||||
|
expect(progress.last.total, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
119
test/tile_math_test.dart
Normal file
119
test/tile_math_test.dart
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:rippr/src/geo/geo.dart' show LatLon;
|
||||||
|
import 'package:rippr/src/tiles/tile_math.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('tilesForBounds', () {
|
||||||
|
test('a single point covers exactly one tile per zoom level', () {
|
||||||
|
final tiles = tilesForBounds(
|
||||||
|
minLat: 51.0,
|
||||||
|
maxLat: 51.0,
|
||||||
|
minLon: -114.0,
|
||||||
|
maxLon: -114.0,
|
||||||
|
minZoom: 10,
|
||||||
|
maxZoom: 12,
|
||||||
|
);
|
||||||
|
// One tile at each of the three zoom levels.
|
||||||
|
expect(tiles.length, 3);
|
||||||
|
expect(tiles.map((t) => t.z).toSet(), {10, 11, 12});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the same box covers more tiles at a higher zoom', () {
|
||||||
|
final low = tilesForBounds(
|
||||||
|
minLat: 50.99,
|
||||||
|
maxLat: 51.01,
|
||||||
|
minLon: -114.01,
|
||||||
|
maxLon: -113.99,
|
||||||
|
minZoom: 10,
|
||||||
|
maxZoom: 10,
|
||||||
|
);
|
||||||
|
final high = tilesForBounds(
|
||||||
|
minLat: 50.99,
|
||||||
|
maxLat: 51.01,
|
||||||
|
minLon: -114.01,
|
||||||
|
maxLon: -113.99,
|
||||||
|
minZoom: 14,
|
||||||
|
maxZoom: 14,
|
||||||
|
);
|
||||||
|
expect(high.length, greaterThan(low.length));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws rather than silently truncating past the cap', () {
|
||||||
|
expect(
|
||||||
|
() => tilesForBounds(
|
||||||
|
minLat: -80,
|
||||||
|
maxLat: 80,
|
||||||
|
minLon: -170,
|
||||||
|
maxLon: 170,
|
||||||
|
minZoom: 10,
|
||||||
|
maxZoom: 14,
|
||||||
|
),
|
||||||
|
throwsA(isA<TooManyTilesException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tiles are deduplicated across an overlapping request', () {
|
||||||
|
final tiles = tilesForBounds(
|
||||||
|
minLat: 51.0,
|
||||||
|
maxLat: 51.001,
|
||||||
|
minLon: -114.0,
|
||||||
|
maxLon: -113.999,
|
||||||
|
minZoom: 5,
|
||||||
|
maxZoom: 5,
|
||||||
|
);
|
||||||
|
// A Set literally cannot contain a duplicate; this documents the intent that
|
||||||
|
// adjacent/overlapping coverage collapses rather than being counted twice.
|
||||||
|
expect(tiles.toSet().length, tiles.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('tilesAlongRoute', () {
|
||||||
|
test('costs far less than the bounding-box rectangle for a route that '
|
||||||
|
'zigzags across its own bounding box', () {
|
||||||
|
// A route that ping-pongs in latitude while advancing in longitude -- its
|
||||||
|
// bounding box is a full rectangle, but the corridor only has to cover the
|
||||||
|
// narrow path actually ridden through that rectangle.
|
||||||
|
final route = [
|
||||||
|
for (var i = 0; i <= 40; i++)
|
||||||
|
LatLon(51.0 + (i.isEven ? 0.0 : 0.3), -114.0 + i * 0.02),
|
||||||
|
];
|
||||||
|
|
||||||
|
final corridor = tilesAlongRoute(
|
||||||
|
route,
|
||||||
|
minZoom: 12,
|
||||||
|
maxZoom: 12,
|
||||||
|
bufferMeters: 150,
|
||||||
|
);
|
||||||
|
final rectangle = tilesForBounds(
|
||||||
|
minLat: 51.0,
|
||||||
|
maxLat: 51.3,
|
||||||
|
minLon: -114.0,
|
||||||
|
maxLon: -113.2,
|
||||||
|
minZoom: 12,
|
||||||
|
maxZoom: 12,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(corridor.length, lessThan(rectangle.length),
|
||||||
|
reason: 'the whole point of a corridor is fewer tiles than the bounding box');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an empty route needs no tiles', () {
|
||||||
|
expect(tilesAlongRoute([], minZoom: 10, maxZoom: 12), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws past the cap, same as tilesForBounds', () {
|
||||||
|
final route = [for (var i = 0; i < 500; i++) LatLon(i * 0.1 - 25, i * 0.1 - 25)];
|
||||||
|
expect(
|
||||||
|
() => tilesAlongRoute(route, minZoom: 12, maxZoom: 14, bufferMeters: 500),
|
||||||
|
throwsA(isA<TooManyTilesException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('estimatedSizeMb', () {
|
||||||
|
test('scales linearly with tile count', () {
|
||||||
|
expect(estimatedSizeMb(0), 0);
|
||||||
|
expect(estimatedSizeMb(1024, avgTileSizeKb: 1), closeTo(1.0, 1e-9));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user