diff --git a/docs/v3/README.md b/docs/v3/README.md index 4e9aeda..f8d9511 100644 --- a/docs/v3/README.md +++ b/docs/v3/README.md @@ -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-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-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-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) | diff --git a/docs/v3/V3-11-offline-tiles.md b/docs/v3/V3-11-offline-tiles.md index 76553e7..475399e 100644 --- a/docs/v3/V3-11-offline-tiles.md +++ b/docs/v3/V3-11-offline-tiles.md @@ -1,6 +1,6 @@ # 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 Have map tiles available where there is no signal. @@ -49,3 +49,65 @@ rectangle — far fewer tiles for the same usefulness. ## Out of scope 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). diff --git a/lib/src/app/providers.dart b/lib/src/app/providers.dart index b312a27..4818d46 100644 --- a/lib/src/app/providers.dart +++ b/lib/src/app/providers.dart @@ -6,16 +6,19 @@ /// instrumented test once wiped a real device's rides. library; +import 'dart:io'; + import 'package:drift/drift.dart' show driftRuntimeOptions; import 'package:drift_flutter/drift_flutter.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path_provider/path_provider.dart'; import '../config/config.dart'; +import '../crash/crash_reporter.dart'; import '../data/database.dart'; +import '../data/route_plan_repository.dart'; import '../data/trip_repository.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_coordinator.dart'; import '../recording/geolocator_location_source.dart'; @@ -24,6 +27,8 @@ import '../recording/recording_engine.dart'; import '../recording/wakelock_controller.dart'; import '../telemetry/live_telemetry.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. /// @@ -183,3 +188,41 @@ final routePlanProvider = StreamProvider.autoDispose.family( final routeWaypointsProvider = StreamProvider.autoDispose.family, int>( (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((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((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((ref) async { + final cache = await ref.watch(tileCacheProvider.future); + return cache.sizeBytes(); +}); diff --git a/lib/src/tiles/cached_tile_provider.dart b/lib/src/tiles/cached_tile_provider.dart new file mode 100644 index 0000000..d2939cb --- /dev/null +++ b/lib/src/tiles/cached_tile_provider.dart @@ -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 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 _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 _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; +} diff --git a/lib/src/tiles/tile_cache.dart b/lib/src/tiles/tile_cache.dart new file mode 100644 index 0000000..3576759 --- /dev/null +++ b/lib/src/tiles/tile_cache.dart @@ -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 put(TileKey key, Uint8List bytes); + Future get(TileKey key); + Future sizeBytes(); + Future clear(); + Future 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 = {}; + 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 _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; + for (final entry in raw.entries) { + final v = entry.value as Map; + _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 _saveManifest() => _manifestFile.writeAsString( + jsonEncode({ + for (final e in _manifest.entries) + e.key: {'bytes': e.value.bytes, 'lastAccess': e.value.lastAccess}, + }), + ); + + @override + Future 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 _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 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 sizeBytes() async { + await _ensureLoaded(); + return _totalBytes(); + } + + @override + Future 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 dispose() async {} +} diff --git a/lib/src/tiles/tile_downloader.dart b/lib/src/tiles/tile_downloader.dart new file mode 100644 index 0000000..4df8a51 --- /dev/null +++ b/lib/src/tiles/tile_downloader.dart @@ -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 downloadTiles({ + required List tiles, + required TileCache cache, + required Future 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.delayed(delay); + } +} diff --git a/lib/src/tiles/tile_math.dart b/lib/src/tiles/tile_math.dart new file mode 100644 index 0000000..7e9e2b5 --- /dev/null +++ b/lib/src/tiles/tile_math.dart @@ -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` 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 tilesForBounds({ + required double minLat, + required double maxLat, + required double minLon, + required double maxLon, + required int minZoom, + required int maxZoom, +}) { + final tiles = {}; + 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 tilesAlongRoute( + List points, { + required int minZoom, + required int maxZoom, + double bufferMeters = 300, +}) { + final tiles = {}; + // 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; diff --git a/lib/src/ui/components/ride_map.dart b/lib/src/ui/components/ride_map.dart index 3aa5313..84dbbb7 100644 --- a/lib/src/ui/components/ride_map.dart +++ b/lib/src/ui/components/ride_map.dart @@ -44,12 +44,18 @@ class RideMap extends StatefulWidget { required this.segments, this.height = 320, this.follow = false, + this.tileProvider, }); final List points; final List segments; 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 /// 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 @@ -184,6 +190,7 @@ class _RideMapState extends State with WidgetsBindingObserver { maxNativeZoom: maxTileZoom.toInt(), // Respect OSM's usage policy: render what is looked at, never bulk prefetch. panBuffer: 0, + tileProvider: widget.tileProvider, ), PolylineLayer(polylines: polylines), ], diff --git a/lib/src/ui/detail/trip_detail_screen.dart b/lib/src/ui/detail/trip_detail_screen.dart index 6faafef..75a1d63 100644 --- a/lib/src/ui/detail/trip_detail_screen.dart +++ b/lib/src/ui/detail/trip_detail_screen.dart @@ -326,7 +326,13 @@ class _Body extends StatelessWidget { ), const SizedBox(height: 24), 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), Card( child: Padding( diff --git a/lib/src/ui/record/record_screen.dart b/lib/src/ui/record/record_screen.dart index a75216f..0fc233d 100644 --- a/lib/src/ui/record/record_screen.dart +++ b/lib/src/ui/record/record_screen.dart @@ -369,6 +369,7 @@ class _LiveMap extends ConsumerWidget { segments: segments, height: 220, follow: true, + tileProvider: ref.watch(cachedTileProviderProvider), ); } } diff --git a/lib/src/ui/routes/route_planner_screen.dart b/lib/src/ui/routes/route_planner_screen.dart index 382e4bf..0e692c1 100644 --- a/lib/src/ui/routes/route_planner_screen.dart +++ b/lib/src/ui/routes/route_planner_screen.dart @@ -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 { ), 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 { userAgentPackageName: tileUserAgent, maxNativeZoom: maxTileZoom.toInt(), panBuffer: 0, + tileProvider: ref.watch(cachedTileProviderProvider), ), if (waypoints.length >= 2) PolylineLayer( @@ -232,6 +250,146 @@ class _RoutePlannerScreenState extends ConsumerState { 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 _downloadOfflineTiles( + BuildContext context, + List waypoints, + ) async { + final Set 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( + 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( + 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 tiles; + final TileCache cache; + + @override + State<_DownloadDialog> createState() => _DownloadDialogState(); +} + +class _DownloadDialogState extends State<_DownloadDialog> { + final _token = CancelToken(); + final _client = http.Client(); + StreamSubscription? _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 { diff --git a/lib/src/ui/settings/settings_screen.dart b/lib/src/ui/settings/settings_screen.dart index d55d304..25c2276 100644 --- a/lib/src/ui/settings/settings_screen.dart +++ b/lib/src/ui/settings/settings_screen.dart @@ -157,6 +157,33 @@ class _SettingsBodyState extends ConsumerState<_SettingsBody> { }, ), 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'), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), diff --git a/test/route_planner_screen_test.dart b/test/route_planner_screen_test.dart index 67080d3..1606518 100644 --- a/test/route_planner_screen_test.dart +++ b/test/route_planner_screen_test.dart @@ -164,5 +164,42 @@ void main() { 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( + 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( + 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); + }); }); } diff --git a/test/tile_cache_test.dart b/test/tile_cache_test.dart new file mode 100644 index 0000000..03b33df --- /dev/null +++ b/test/tile_cache_test.dart @@ -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); + }); +} diff --git a/test/tile_downloader_test.dart b/test/tile_downloader_test.dart new file mode 100644 index 0000000..64c0207 --- /dev/null +++ b/test/tile_downloader_test.dart @@ -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 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 = []; + 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()); + 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 = []; + + 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); + }); +} diff --git a/test/tile_math_test.dart b/test/tile_math_test.dart new file mode 100644 index 0000000..1c858e6 --- /dev/null +++ b/test/tile_math_test.dart @@ -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()), + ); + }); + + 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()), + ); + }); + }); + + group('estimatedSizeMb', () { + test('scales linearly with tile count', () { + expect(estimatedSizeMb(0), 0); + expect(estimatedSizeMb(1024, avgTileSizeKb: 1), closeTo(1.0, 1e-9)); + }); + }); +}