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,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;
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user