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:
2026-08-23 20:07:47 -05:00
parent d3974eb4e1
commit 10040e6985
13 changed files with 580 additions and 38 deletions

View File

@@ -11,6 +11,7 @@ import 'dart:io';
import 'package:drift/drift.dart' show driftRuntimeOptions;
import 'package:drift_flutter/drift_flutter.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:path_provider/path_provider.dart';
import '../config/config.dart';
@@ -28,7 +29,9 @@ import '../recording/wakelock_controller.dart';
import '../telemetry/live_telemetry.dart';
import '../telemetry/telemetry_uploader.dart';
import '../tiles/cached_tile_provider.dart';
import '../tiles/map_connectivity.dart';
import '../tiles/tile_cache.dart';
import '../tiles/tile_config.dart';
/// The Drift database, opened against app-private storage.
///
@@ -222,7 +225,7 @@ final tileCacheProvider = FutureProvider<TileCache>((ref) async {
final cachedTileProviderProvider = Provider<CachedTileProvider?>((ref) {
final cache = ref.watch(tileCacheProvider).valueOrNull;
if (cache == null) return null;
return CachedTileProvider(cache: cache);
return CachedTileProvider(cache: cache, connectivity: ref.watch(mapConnectivityProvider));
});
/// Settings' "Offline tiles" section reads this rather than [tileCacheProvider]
@@ -232,3 +235,39 @@ final tileCacheSizeProvider = FutureProvider<int>((ref) async {
final cache = await ref.watch(tileCacheProvider.future);
return cache.sizeBytes();
});
// --- Map connectivity / skeleton mode (UI-02) --------------------------------
/// One [MapConnectivityState] shared by every map in the app -- see the class's own
/// doc comment for why a single instance, rather than one per map widget, is correct
/// here. A [ChangeNotifierProvider] rather than a plain value: widgets need to rebuild
/// when `skeletonMode` flips, not just read it once.
final mapConnectivityProvider = ChangeNotifierProvider<MapConnectivityState>((ref) {
final client = http.Client();
ref.onDispose(client.close);
// `ChangeNotifierProvider` already disposes the notifier it returns on teardown --
// no separate `ref.onDispose(state.dispose)` here, which would double-dispose it.
return MapConnectivityState(
// A single, deterministic tile (the whole-world zoom-0 overview, always valid for
// any XYZ tile scheme) rather than whatever tile happens to be in view -- the probe
// exists to answer "is the tile host reachable at all", not to speculatively refetch
// the current viewport.
probe: () async {
try {
final url = tileUrlTemplate
.replaceFirst('{s}', tileSubdomains.first)
.replaceFirst('{z}', '0')
.replaceFirst('{x}', '0')
.replaceFirst('{y}', '0')
.replaceFirst('{r}', '');
final response = await client
.get(Uri.parse(url), headers: {'User-Agent': tileUserAgent})
.timeout(const Duration(seconds: 5));
return response.statusCode == 200;
} catch (_) {
return false;
}
},
);
});