Files
rippr/lib/src/tiles/cached_tile_provider.dart
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

109 lines
3.6 KiB
Dart

/// Feeds `RideMap`'s `TileLayer` from [TileCache] first, network second -- the same
/// cache a V3-11 pre-download populates, so a rider who downloaded a corridor actually
/// sees it render without a request going out. See V3-11.
library;
import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
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, 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(
key: TileKey(coordinates.z, coordinates.x, coordinates.y),
url: getTileUrl(coordinates, options),
headers: headers,
cache: cache,
client: _client,
connectivity: connectivity,
);
}
class _CacheBackedImage extends ImageProvider<_CacheBackedImage> {
const _CacheBackedImage({
required this.key,
required this.url,
required this.headers,
required this.cache,
required this.client,
this.connectivity,
});
final TileKey key;
final String url;
final Map<String, String> headers;
final TileCache cache;
final http.Client client;
final MapConnectivityState? connectivity;
@override
Future<_CacheBackedImage> obtainKey(ImageConfiguration configuration) =>
SynchronousFuture(this);
@override
ImageStreamCompleter loadImage(_CacheBackedImage key, ImageDecoderCallback decode) =>
MultiFrameImageStreamCompleter(
codec: _load(decode),
scale: 1.0,
debugLabel: url,
);
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 {
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;
}
}
@override
bool operator ==(Object other) => other is _CacheBackedImage && other.key == key;
@override
int get hashCode => key.hashCode;
}