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).
114 lines
6.3 KiB
Markdown
114 lines
6.3 KiB
Markdown
# 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).
|