Files
samplez/rippr-flutter-src/docs/v3/V3-11-offline-tiles.md
uhryniuk 4c634354bd Refresh the Flutter snapshot: v3 tickets V3-04 through V3-16, plus a fresh installable APK
V3-04 through V3-07, V3-10, V3-16 shipped complete; V3-11/V3-12/V3-14 shipped code-complete pending device/account verification; V3-08/V3-09 deferred behind a new V3-17 (self-hosted OSRM investigation). 316 tests passing, up from 221.

The APK is a fresh release build (debug-signed, no release signing config exists yet) with two build fixes applied: core library desugaring enabled for flutter_local_notifications, and sentry_flutter bumped to 9.27.0 (8.14.2's bundled Kotlin plugin was incompatible with this project's Kotlin 2.4.0 toolchain).
2026-08-19 13:36:04 -05:00

6.3 KiB

V3-11 — Offline tile pre-download

Phase Ride management · Depends on V3-04 · Size M · Status Partially done

Goal

Have map tiles available where there is no signal.

Context

flutter_map caches what it renders, so a re-viewed ride works. A mountain ride with no signal shows blank tiles — precisely where a map is most wanted.

Design

Respect OSM's tile usage policy. Bulk prefetching their public servers is prohibited and would get the app blocked. This constraint decides the design:

  • Pre-download only a user-chosen area, at a limited zoom range, with a visible tile count and size estimate before starting
  • Rate-limited, sequential, cancellable
  • If this becomes a headline feature, move to a paid tile provider or self-hosted tiles. Do not scale it on OSM's donated infrastructure.

Natural pairing with V3-07: pre-download the corridor along a planned route rather than a rectangle — far fewer tiles for the same usefulness.

Implementation

  1. Persistent tile cache with a size cap and eviction (flutter_map_cache or similar)
  2. Area selection on the map, plus a "download along this route" option
  3. Tile count and MB estimate before any request
  4. Sequential fetch with a delay, a progress indicator and cancellation
  5. Settings: cache size, and a way to clear it

Acceptance criteria

  • A downloaded area renders with the network off
  • Count and size shown before download starts
  • Cancellable mid-download, keeping what has already arrived
  • A hard cap on tiles per request — no unbounded area selection
  • Cache size visible and clearable

Tests

  • Tile-count maths for a bounding box across zoom levels
  • Cache eviction at the cap
  • Cancellation leaves a consistent cache
  • Manual: aeroplane mode over a downloaded area

Risks

  • Abusing OSM's servers. Cap, rate-limit, and be conservative. A blocked user agent would break the map for everyone.
  • Storage growth. Tiles add up fast; the cap is not optional.

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).