Files
rippr/docs/ui-redesign/UI-02-offline-skeleton-map.md
uhryniuk 10040e6985 UI-02: animated skeleton map when tiles can't be fetched
Adds MapConnectivityState, a shared tracker of tile-fetch outcomes (cache
miss + network failure) that flips every map into an animated skeleton
after 3 consecutive failures and recovers on a single success -- either an
ordinary fetch succeeding, or (once TileLayer has been fully unmounted in
skeleton mode) a periodic single-tile probe every 15s. Detected at the
fetch level rather than via an OS connectivity API, since a captive portal
or degraded connection can report "online" while every real fetch times
out.

SkeletonMapLayer reuses the Stitch exports' 40px grid-overlay treatment
with a shimmer sweep, replacing TileLayer entirely (never fetching
underneath its own placeholder) while markers/polylines keep rendering
since they come from local data. RideMap and the route planner's
independent FlutterMap both wire this in via a plain skeletonMode bool.

Moved the tile-source constants into a new tiles/tile_config.dart so the
connectivity probe (in the app-layer composition root) doesn't need to
import from ui/ to build its request URL.

Verified end-to-end on a real emulator: cut network, cleared the tile
cache, confirmed the skeleton renders after real fetch failures, then
confirmed automatic recovery within one probe interval once network
returned -- not just via the widget/unit tests that also cover this.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Xki7YAcc2TiN2PRZJ2tXr
2026-08-23 20:07:47 -05:00

149 lines
9.2 KiB
Markdown

# UI-02 — Offline / no-connection skeleton map
**Depends on** UI-01 · **Size** S · **Status** Done
## Goal
When the map has no tiles to show — no cached tiles for the current view and no network
to fetch fresh ones — show an animated skeleton in place of a broken or blank map, on
every tab, since UI-01 makes the map a permanent background presence rather than
something a rider opts into per-screen.
## Context
By explicit instruction: *"If we do not have connection for the map, we should just show
an animated skeleton map so it doesn't look weird."* Once UI-01 ships, the map is always
on screen somewhere — a blank grey rectangle or a grid of broken-image icons behind every
tab, all the time, on a phone with no signal, is a much worse look than it was when the
map only appeared on the one screen a rider explicitly opened.
This composes with existing infrastructure rather than duplicating it:
`CachedTileProvider` (V3-11) already tries the offline tile cache before the network, so
"no connection" in practice means *both* the cache and the network failed for the tiles
currently in view.
## Design
Detect at the `CachedTileProvider`/`TileLayer` level, not by asking the OS for
connectivity state directly — a connectivity API can report "online" while the actual
tile fetch still times out (captive portal, degraded connection), and the tile fetch's
own success/failure is the only thing that actually matters here.
- Track a rolling failure count for in-flight tile fetches (cache miss **and** network
fetch failed) over a short window. Crossing a small threshold (e.g. 3 consecutive
failures) flips the map into skeleton mode; a single successful fetch flips it back.
- Skeleton mode replaces the `TileLayer` with a static, non-fetching placeholder — never
a widget that itself keeps trying and failing in a loop.
- Placeholder look: a shimmer/gradient-sweep animation over a neutral grid pattern
(reuse the "technical grid overlay" treatment already present in the Stitch exports'
`map-grid-overlay` CSS — 40px faint grid lines — as the static base under the shimmer),
not a spinner. A map-shaped thing that is clearly still loading, not an error state.
- Recheck periodically (not on every frame) so the map recovers automatically the moment
connectivity returns, without the rider having to do anything.
## Implementation
1. Wrap `CachedTileProvider` (or add a sibling) that reports fetch outcomes to a small
`MapConnectivityState` — rolling window, threshold, debounced flip.
2. `SkeletonMapLayer` widget: the grid + shimmer, sized to fill the same space a
`TileLayer` would.
3. `RideMap`/`PersistentMapBackground` (from UI-01) watches `MapConnectivityState` and
swaps `TileLayer` for `SkeletonMapLayer` when in skeleton mode — markers/polylines
(the rider's own path, planned route) keep rendering on top either way, since those
come from local data, not tiles.
4. A cap on retry frequency while in skeleton mode — this must not turn into a tile-
fetch retry loop that itself violates OSM's usage policy the way V3-11's design
explicitly guards against.
## Acceptance criteria
- [ ] With no cached tiles and no network, the map area shows an animated skeleton, not
blank space or broken-image icons
- [ ] Recovery is automatic: once tiles become fetchable again, the skeleton is replaced
without user action
- [ ] The rider's live position/path and any planned route still render on top of the
skeleton (only the base tiles are missing)
- [ ] Skeleton mode does not itself hammer the tile server with retries
- [ ] Behaves correctly across all four tabs (UI-01's shared background map), not just
the Map tab
## Tests
- Unit: the failure-count/threshold/debounce logic that decides skeleton vs. live,
exercised with fakes (no real network) — mirrors V3-11's `tile_downloader_test.dart`
pattern of injecting fake fetch outcomes
- Widget: `SkeletonMapLayer` renders when connectivity state says offline; `TileLayer`
renders when it says online
- Widget: markers/polylines still render in skeleton mode
## Risks
- Flapping between skeleton and live on a marginal connection would be worse than
either state alone — the debounce window is what prevents this; tune it based on real
behavior, not a guess (echoes V3-13's "measure, don't tune blind" discipline).
- Must not let skeleton-mode retries become the kind of bulk/rapid tile request V3-11's
design explicitly exists to avoid.
## Out of scope
General offline-mode UX beyond the map itself (e.g. graying out map-dependent buttons).
Manual retry controls — automatic recovery is the whole point.
## Outcome
Built exactly to the ticket's Design section. `lib/src/tiles/map_connectivity.dart`'s
`MapConnectivityState` tracks consecutive tile-fetch failures (`skeletonFailureThreshold
= 3`), flips to skeleton mode on the threshold, and recovers instantly on a single
success — either an ordinary fetch succeeding (while still below threshold, resetting
the run) or, once already in skeleton mode, a periodic single-tile probe
(`skeletonProbeInterval = 15s`) succeeding. The probe is a deliberate design choice: once
skeleton mode starts, `RideMap`/the route planner fully unmount their `TileLayer` (no
ongoing requests at all, per the ticket's "never a widget that itself keeps trying and
failing in a loop"), so nothing generates ordinary fetch outcomes to recover from — the
probe exists specifically to test the water on the map's behalf, at a low, fixed
cadence, using one deterministic tile (the zoom-0 whole-world overview) rather than
whatever happened to be in view.
`CachedTileProvider` (`lib/src/tiles/cached_tile_provider.dart`) now takes an optional
`MapConnectivityState? connectivity` and reports outcomes around its network fetch only
— a cache hit is silently skipped since it says nothing about current connectivity
either way, matching the ticket's "cache miss *and* network fetch failed" definition of
a failure exactly. `mapConnectivityProvider` (`app/providers.dart`) is the one shared
instance every map watches, per the class's own reasoning: connectivity is a fact about
the network, not about which map widget happens to be on screen.
`SkeletonMapLayer` (`lib/src/ui/components/skeleton_map_layer.dart`) is the 40px faint
grid (reusing the Stitch exports' `.map-grid-overlay` treatment directly, `rgba(255,255,
255,0.03-0.05)` → `Color(0x0DFFFFFF)`) with a `LinearGradient` shimmer sweeping across it
on a 1800ms repeating cycle, painted via `ShaderMask`/`CustomPainter` rather than a
translated widget so it doesn't need to know its own pixel size. `RideMap` gained a
`skeletonMode` bool (a plain constructor param, not a `ConsumerWidget` watch — consistent
with how `tileProvider` is already handed down rather than looked up) that swaps
`TileLayer` for `SkeletonMapLayer` while leaving `PolylineLayer`/markers untouched, since
those come from local data. The route planner manages its own independent `FlutterMap`
and does the same swap itself, watching `mapConnectivityProvider` directly.
**Refactor along the way:** moved `tileUrlTemplate`/`tileSubdomains`/`tileMaxNativeZoom`/
`tileUserAgent` out of `ride_map.dart` into a new `lib/src/tiles/tile_config.dart`, and
had `ride_map.dart` re-export them for its existing importers. `app/providers.dart` (the
composition root) needed these constants for the connectivity probe's URL, and importing
a `ui/` file from the `app/` layer would have been a real layering violation the tiles
layer itself doesn't have a reason to accept — better to give the constants a home in
the layer they actually describe (tile fetching) than to route around the smell.
**Bug caught by the first real test run:** `mapConnectivityProvider`'s initial
implementation called `ref.onDispose(state.dispose)` in addition to
`ChangeNotifierProvider`'s own automatic disposal of the notifier it returns — a
double-dispose that threw "A MapConnectivityState was used after being disposed" and
failed five `route_planner_screen_test.dart` tests outright. Fixed by removing the
redundant manual dispose call.
**Tests:** `flutter analyze` clean. `flutter test` green at 328 tests (316 + 3 new
`ride_map_test.dart` skeleton-mode cases + 9 new `map_connectivity_test.dart` unit cases
covering threshold behavior, the reset-on-success case, notification-only-on-actual-
change, and the probe loop's recovery and its refusal to run at all while still live).
**Android emulator verification** (`Medium_Phone_API_35`) — a full end-to-end real-
network test, not just widget tests: cut the emulator's actual network
(`svc wifi disable` + `svc data disable`, confirmed via a failing `ping`), cleared the
offline tile cache via Settings, and navigated to an uncached view (the route planner's
default zoom-14 view, which the shell's already-cached Calgary-area background tiles
didn't cover). After the threshold of real failed fetches, the skeleton grid rendered
correctly — clearly a "still loading" placeholder, not blank space or broken-image
icons. Re-enabled the network and confirmed automatic recovery within one probe interval
with no user action, exactly per the acceptance criteria. The shared shell background
map (already displaying previously-cached tiles from memory) was unaffected throughout,
as expected since it never needed a fresh fetch during the test window.