Full UI redesign pass complete: persistent tab shell with an always-visible background map, Modern Professional Dark theme, monochrome dark map tiles, offline skeleton map, a shared GlassPanel/FloatingPill component kit, customizable HUD telemetry widgets, and the Map HUD / Plan & Route Planning / Rides History screen rebuilds. 374 tests passing, up from 316. The APK is a fresh release build (debug-signed, no release signing config exists yet). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Xki7YAcc2TiN2PRZJ2tXr
9.2 KiB
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
TileLayerwith 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-overlayCSS — 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
- Wrap
CachedTileProvider(or add a sibling) that reports fetch outcomes to a smallMapConnectivityState— rolling window, threshold, debounced flip. SkeletonMapLayerwidget: the grid + shimmer, sized to fill the same space aTileLayerwould.RideMap/PersistentMapBackground(from UI-01) watchesMapConnectivityStateand swapsTileLayerforSkeletonMapLayerwhen 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.- 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.dartpattern of injecting fake fetch outcomes - Widget:
SkeletonMapLayerrenders when connectivity state says offline;TileLayerrenders 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.