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:
@@ -11,17 +11,24 @@ import 'package:flutter/painting.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'map_connectivity.dart';
|
||||
import 'tile_cache.dart';
|
||||
import 'tile_math.dart';
|
||||
|
||||
class CachedTileProvider extends TileProvider {
|
||||
CachedTileProvider({required this.cache, http.Client? client})
|
||||
CachedTileProvider({required this.cache, this.connectivity, http.Client? client})
|
||||
: _client = client ?? http.Client(),
|
||||
super();
|
||||
|
||||
final TileCache cache;
|
||||
final http.Client _client;
|
||||
|
||||
/// UI-02: told about every fetch outcome so it can decide whether the map should be
|
||||
/// showing an animated skeleton instead of tiles. Null in tests/callers that don't
|
||||
/// care -- skeleton mode is a UI concern layered on top of caching, not something
|
||||
/// this provider requires to function.
|
||||
final MapConnectivityState? connectivity;
|
||||
|
||||
@override
|
||||
ImageProvider getImage(TileCoordinates coordinates, TileLayer options) =>
|
||||
_CacheBackedImage(
|
||||
@@ -30,6 +37,7 @@ class CachedTileProvider extends TileProvider {
|
||||
headers: headers,
|
||||
cache: cache,
|
||||
client: _client,
|
||||
connectivity: connectivity,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,6 +48,7 @@ class _CacheBackedImage extends ImageProvider<_CacheBackedImage> {
|
||||
required this.headers,
|
||||
required this.cache,
|
||||
required this.client,
|
||||
this.connectivity,
|
||||
});
|
||||
|
||||
final TileKey key;
|
||||
@@ -47,6 +56,7 @@ class _CacheBackedImage extends ImageProvider<_CacheBackedImage> {
|
||||
final Map<String, String> headers;
|
||||
final TileCache cache;
|
||||
final http.Client client;
|
||||
final MapConnectivityState? connectivity;
|
||||
|
||||
@override
|
||||
Future<_CacheBackedImage> obtainKey(ImageConfiguration configuration) =>
|
||||
@@ -62,21 +72,32 @@ class _CacheBackedImage extends ImageProvider<_CacheBackedImage> {
|
||||
|
||||
Future<ui.Codec> _load(ImageDecoderCallback decode) async {
|
||||
final cached = await cache.get(key);
|
||||
// A cache hit says nothing about current connectivity either way -- it's not a
|
||||
// network round trip, so it neither counts as a success nor resets a failure run.
|
||||
final bytes = cached ?? await _fetchAndStore();
|
||||
final buffer = await ui.ImmutableBuffer.fromUint8List(bytes);
|
||||
return decode(buffer);
|
||||
}
|
||||
|
||||
Future<Uint8List> _fetchAndStore() async {
|
||||
final response = await client.get(Uri.parse(url), headers: headers);
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Tile fetch failed: ${response.statusCode} for $url');
|
||||
try {
|
||||
final response = await client.get(Uri.parse(url), headers: headers);
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Tile fetch failed: ${response.statusCode} for $url');
|
||||
}
|
||||
final bytes = response.bodyBytes;
|
||||
// Write-through: viewing a tile online caches it for later, exactly like
|
||||
// flutter_map's own default caching did -- just capped and evictable now.
|
||||
await cache.put(key, bytes);
|
||||
connectivity?.reportSuccess();
|
||||
return bytes;
|
||||
} catch (_) {
|
||||
// UI-02: a cache miss whose network fetch also failed is exactly the "no
|
||||
// connection" signal skeleton mode is watching for -- report it and rethrow so
|
||||
// flutter_map's own error handling for this tile is unchanged.
|
||||
connectivity?.reportFailure();
|
||||
rethrow;
|
||||
}
|
||||
final bytes = response.bodyBytes;
|
||||
// Write-through: viewing a tile online caches it for later, exactly like
|
||||
// flutter_map's own default caching did -- just capped and evictable now.
|
||||
await cache.put(key, bytes);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
108
lib/src/tiles/map_connectivity.dart
Normal file
108
lib/src/tiles/map_connectivity.dart
Normal file
@@ -0,0 +1,108 @@
|
||||
/// UI-02: decides whether the map should show live tiles or an animated skeleton.
|
||||
///
|
||||
/// Driven by actual tile-fetch outcomes, not an OS connectivity API -- a connectivity
|
||||
/// API can report "online" while the real tile fetch still times out (a captive portal,
|
||||
/// a degraded connection), and the fetch's own success or failure is the only thing
|
||||
/// that actually matters to what's on screen.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Consecutive tile-fetch failures (cache miss *and* network fetch failed) before
|
||||
/// switching to skeleton mode. High enough that one blip mid-ride doesn't flash a
|
||||
/// skeleton over an otherwise-live map; three genuine failures in a row is a real
|
||||
/// connectivity problem, not noise.
|
||||
const int skeletonFailureThreshold = 3;
|
||||
|
||||
/// How often skeleton mode probes for recovery. A `TileLayer` is fully unmounted while
|
||||
/// in skeleton mode -- see `RideMap` -- so nothing is generating ordinary fetch
|
||||
/// outcomes to react to; something has to periodically try again on the map's behalf.
|
||||
/// Long enough that this can never look like the retry-storm V3-11's own design exists
|
||||
/// to avoid, short enough that recovery still feels close to automatic.
|
||||
const Duration skeletonProbeInterval = Duration(seconds: 15);
|
||||
|
||||
/// Tracks tile-fetch health and flips between live and skeleton map modes.
|
||||
///
|
||||
/// One instance is shared across every map in the app (see `mapConnectivityProvider`) --
|
||||
/// connectivity is a fact about the network, not about which particular map widget
|
||||
/// happens to be on screen, and sharing it means a failure noticed on one map's fetch
|
||||
/// immediately reflects on every other map too.
|
||||
class MapConnectivityState extends ChangeNotifier {
|
||||
MapConnectivityState({required Future<bool> Function() probe, Duration? probeInterval})
|
||||
: _probe = probe,
|
||||
_probeInterval = probeInterval ?? skeletonProbeInterval;
|
||||
|
||||
final Future<bool> Function() _probe;
|
||||
final Duration _probeInterval;
|
||||
|
||||
int _consecutiveFailures = 0;
|
||||
bool _skeletonMode = false;
|
||||
Timer? _probeTimer;
|
||||
bool _probing = false;
|
||||
|
||||
bool get skeletonMode => _skeletonMode;
|
||||
|
||||
/// A tile fetch actually reached the network and succeeded. Resets the failure
|
||||
/// count and, if already in skeleton mode, recovers immediately -- a single success
|
||||
/// is enough, per the ticket's own acceptance criteria; there's no reason to make a
|
||||
/// rider wait out a timer once the map has proven it works again.
|
||||
void reportSuccess() {
|
||||
_consecutiveFailures = 0;
|
||||
if (_skeletonMode) _setSkeletonMode(false);
|
||||
}
|
||||
|
||||
/// A tile fetch missed the cache and the network fetch also failed.
|
||||
void reportFailure() {
|
||||
// Once in skeleton mode, the `TileLayer` generating these reports is unmounted --
|
||||
// recovery is the probe loop's job instead, not further failure counting.
|
||||
if (_skeletonMode) return;
|
||||
_consecutiveFailures++;
|
||||
if (_consecutiveFailures >= skeletonFailureThreshold) {
|
||||
_setSkeletonMode(true);
|
||||
}
|
||||
}
|
||||
|
||||
void _setSkeletonMode(bool value) {
|
||||
if (_skeletonMode == value) return;
|
||||
_skeletonMode = value;
|
||||
if (value) {
|
||||
_startProbing();
|
||||
} else {
|
||||
_stopProbing();
|
||||
_consecutiveFailures = 0;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _startProbing() {
|
||||
_probeTimer?.cancel();
|
||||
_probeTimer = Timer.periodic(_probeInterval, (_) => _runProbe());
|
||||
}
|
||||
|
||||
void _stopProbing() {
|
||||
_probeTimer?.cancel();
|
||||
_probeTimer = null;
|
||||
}
|
||||
|
||||
Future<void> _runProbe() async {
|
||||
// A probe already in flight when the timer fires again means the last one is
|
||||
// taking longer than the interval -- exactly the slow/degraded-connection case a
|
||||
// second overlapping probe would make worse, not better.
|
||||
if (_probing) return;
|
||||
_probing = true;
|
||||
try {
|
||||
final recovered = await _probe();
|
||||
if (recovered) _setSkeletonMode(false);
|
||||
} finally {
|
||||
_probing = false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stopProbing();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
18
lib/src/tiles/tile_config.dart
Normal file
18
lib/src/tiles/tile_config.dart
Normal file
@@ -0,0 +1,18 @@
|
||||
/// UI-09: the one place the app's tile source is named, so `RideMap`, the route
|
||||
/// planner's own `FlutterMap`, and (UI-02) the connectivity probe in `app/providers.dart`
|
||||
/// all point at the same host with the same parameters rather than three copies that
|
||||
/// could silently drift apart.
|
||||
library;
|
||||
|
||||
/// CARTO's dark basemap. `{s}` is one of [tileSubdomains]; `{r}` is resolved by
|
||||
/// `TileLayer`'s own `retinaMode` to `@2x` (or empty) based on device pixel ratio.
|
||||
const String tileUrlTemplate = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
|
||||
const List<String> tileSubdomains = ['a', 'b', 'c', 'd'];
|
||||
|
||||
/// CARTO's dark tiles are natively rendered up to this zoom -- passed to
|
||||
/// `TileLayer.maxNativeZoom` so a future bump to the app's own zoom ceiling
|
||||
/// (`RideMap.maxTileZoom`) doesn't also require re-deriving this number.
|
||||
const int tileMaxNativeZoom = 20;
|
||||
|
||||
/// Identifies the app to the tile host's servers. Anonymous bulk requests get 403.
|
||||
const String tileUserAgent = 'com.rippr.port';
|
||||
Reference in New Issue
Block a user