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
This commit is contained in:
@@ -20,7 +20,7 @@ v3 held to.
|
||||
| # | Ticket | Size | Depends on | Status |
|
||||
|---|---|---|---|---|
|
||||
| [UI-01](UI-01-tab-shell-background-map.md) | Persistent tab shell with an always-visible background map | L | — | Done |
|
||||
| [UI-02](UI-02-offline-skeleton-map.md) | Offline / no-connection skeleton map | S | UI-01 | Not started |
|
||||
| [UI-02](UI-02-offline-skeleton-map.md) | Offline / no-connection skeleton map | S | UI-01 | Done |
|
||||
| [UI-03](UI-03-glass-component-kit.md) | Shared floating-glass component kit | M | UI-08 | Not started |
|
||||
| [UI-04](UI-04-customizable-hud-widgets.md) | Customizable HUD telemetry widgets (drag, resize, visibility toggle) | L | UI-03 | Not started |
|
||||
| [UI-05](UI-05-map-hud-record-screen.md) | Map HUD: Record screen redesign | M | UI-01, UI-03, UI-04 | Not started |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# UI-02 — Offline / no-connection skeleton map
|
||||
|
||||
**Depends on** UI-01 · **Size** S · **Status** Not started
|
||||
**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
|
||||
@@ -80,3 +80,69 @@ own success/failure is the only thing that actually matters here.
|
||||
## 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.
|
||||
|
||||
Reference in New Issue
Block a user