Files
samplez/rippr-flutter-src/docs/ui-redesign/UI-02-offline-skeleton-map.md
uhryniuk 587f75eab4 Refresh the Flutter snapshot: UI-01 through UI-09, plus a fresh installable APK
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
2026-08-24 14:41:35 -05:00

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