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;
}
},
);
});

View File

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

View 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();
}
}

View 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';

View File

@@ -94,6 +94,7 @@ class ShellScaffold extends ConsumerWidget {
fill: true,
showEmptyLabel: false,
tileProvider: ref.watch(cachedTileProviderProvider),
skeletonMode: ref.watch(mapConnectivityProvider).skeletonMode,
),
),
),

View File

@@ -25,7 +25,11 @@ import 'package:latlong2/latlong.dart' as ll;
import '../../domain/models.dart';
import '../../geo/geo.dart' as geo;
import '../../tiles/tile_config.dart';
import '../theme.dart' show ripprRadiusLarge;
import 'skeleton_map_layer.dart';
export '../../tiles/tile_config.dart';
/// Metres. Render-only: a 3-hour ride is ~21,600 points and would jank undecimated.
const double simplifyEpsilonM = 5.0;
@@ -34,27 +38,14 @@ const double simplifyEpsilonM = 5.0;
/// fiddly, and flutter_map has no equivalent either. Buckets also read better at a glance.
const int speedBucketKmh = 10;
/// This app's own zoom ceiling -- not raised to CARTO's native 20 (see
/// [tileMaxNativeZoom]) without deliberately re-verifying the fit/follow-zoom logic
/// tuned against 19 by earlier tickets. Exceeding it renders an empty grid.
/// This app's own zoom ceiling -- not raised to CARTO's native 20 ([tileMaxNativeZoom])
/// without deliberately re-verifying the fit/follow-zoom logic tuned against 19 by
/// earlier tickets. Exceeding it renders an empty grid.
const double maxTileZoom = 19.0;
/// CARTO's dark tiles are natively rendered up to this zoom (one past [maxTileZoom]) --
/// passed to `TileLayer.maxNativeZoom` so a future bump to [maxTileZoom] doesn't also
/// require re-deriving this number.
const int tileMaxNativeZoom = 20;
/// 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'];
/// What a very short ride falls back to, so streets stay visible.
const double shortRideZoom = 17.0;
/// Identifies the app to the tile host's servers. Anonymous bulk requests get 403.
const String tileUserAgent = 'com.rippr.port';
class RideMap extends StatefulWidget {
const RideMap({
super.key,
@@ -65,12 +56,21 @@ class RideMap extends StatefulWidget {
this.tileProvider,
this.fill = false,
this.showEmptyLabel = true,
this.skeletonMode = false,
});
final List<TrackPoint> points;
final List<Segment> segments;
final double height;
/// UI-02: true when `MapConnectivityState` has decided neither the offline cache nor
/// the network can currently produce tiles. Swaps `TileLayer` for `SkeletonMapLayer`
/// -- markers/polylines are unaffected, since those come from local data, not tiles.
/// A plain `bool` rather than watching the connectivity state directly, matching how
/// [tileProvider] is already handed down rather than looked up -- `RideMap` stays a
/// plain `StatefulWidget`, not a `ConsumerWidget`.
final bool skeletonMode;
/// UI-01: fills whatever space the parent gives it (a `Positioned.fill`/`Expanded`
/// ancestor) instead of the fixed [height] -- for the persistent full-screen
/// background map behind every tab, where there is no card to size it.
@@ -238,9 +238,14 @@ class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
},
),
children: [
// UI-02: the skeleton replaces the TileLayer entirely rather than sitting on
// top of it -- a widget that keeps trying and failing to fetch underneath its
// own placeholder would be exactly the retry loop the ticket warns against.
if (widget.skeletonMode)
const SkeletonMapLayer()
// Omitted entirely while backgrounded -- not just visually hidden -- so no
// tile request can fire off-screen. See the lifecycle observer above.
if (!_backgrounded)
else if (!_backgrounded)
TileLayer(
urlTemplate: tileUrlTemplate,
subdomains: tileSubdomains,

View File

@@ -0,0 +1,96 @@
/// UI-02: what the map shows in place of tiles when neither the offline cache nor the
/// network can produce them -- a placeholder that clearly reads as "still loading", not
/// blank space, not a grid of broken-image icons, and not an error.
///
/// The 40px faint grid is the same "technical grid overlay" treatment already present
/// in the Stitch exports (`.map-grid-overlay`, `rgba(255,255,255,0.03-0.05)` lines every
/// 40px) -- reused rather than invented, so the skeleton still looks like it belongs to
/// this app's map even with no tiles under it.
library;
import 'package:flutter/material.dart';
class SkeletonMapLayer extends StatefulWidget {
const SkeletonMapLayer({super.key});
@override
State<SkeletonMapLayer> createState() => _SkeletonMapLayerState();
}
class _SkeletonMapLayerState extends State<SkeletonMapLayer>
with SingleTickerProviderStateMixin {
late final _controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1800),
)..repeat();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final ground = Theme.of(context).scaffoldBackgroundColor;
return ColoredBox(
color: ground,
child: SizedBox.expand(
child: CustomPaint(
painter: _GridPainter(),
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) => ShaderMask(
blendMode: BlendMode.srcATop,
shaderCallback: (bounds) {
// The sweep runs from just off the left edge to just off the right,
// parameterised on the animation value -- a `LinearGradient` whose
// stops slide across the box rather than a physically translated
// widget, so it never has to know the box's actual pixel size itself.
final t = _controller.value;
return LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: const [
Colors.transparent,
Colors.white24,
Colors.transparent,
],
stops: [
(t - 0.3).clamp(0.0, 1.0),
t.clamp(0.0, 1.0),
(t + 0.3).clamp(0.0, 1.0),
],
).createShader(bounds);
},
child: const SizedBox.expand(),
),
),
),
),
);
}
}
class _GridPainter extends CustomPainter {
const _GridPainter();
static const double _spacing = 40;
static const Color _lineColor = Color(0x0DFFFFFF); // white @ ~5% opacity
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = _lineColor
..strokeWidth = 1;
for (var x = 0.0; x <= size.width; x += _spacing) {
canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint);
}
for (var y = 0.0; y <= size.height; y += _spacing) {
canvas.drawLine(Offset(0, y), Offset(size.width, y), paint);
}
}
@override
bool shouldRepaint(_GridPainter oldDelegate) => false;
}

View File

@@ -331,6 +331,7 @@ class _Body extends StatelessWidget {
points: detail.points,
segments: detail.segments,
tileProvider: ref.watch(cachedTileProviderProvider),
skeletonMode: ref.watch(mapConnectivityProvider).skeletonMode,
),
),
if (detail.mapEnabled) const SizedBox(height: 24),

View File

@@ -27,6 +27,7 @@ import '../components/ride_map.dart'
tileSubdomains,
tileUrlTemplate,
tileUserAgent;
import '../components/skeleton_map_layer.dart';
import '../components/stats.dart' show confirmDialog;
import '../format.dart';
@@ -149,15 +150,21 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
repo.addWaypoint(widget.routeId, point.latitude, point.longitude),
),
children: [
TileLayer(
urlTemplate: tileUrlTemplate,
subdomains: tileSubdomains,
retinaMode: true,
userAgentPackageName: tileUserAgent,
maxNativeZoom: tileMaxNativeZoom,
panBuffer: 0,
tileProvider: ref.watch(cachedTileProviderProvider),
),
// UI-02: same skeleton-or-tiles swap as RideMap's background usage --
// this screen manages its own FlutterMap directly rather than through
// RideMap, so it has to watch connectivity and swap the layer itself.
if (ref.watch(mapConnectivityProvider).skeletonMode)
const SkeletonMapLayer()
else
TileLayer(
urlTemplate: tileUrlTemplate,
subdomains: tileSubdomains,
retinaMode: true,
userAgentPackageName: tileUserAgent,
maxNativeZoom: tileMaxNativeZoom,
panBuffer: 0,
tileProvider: ref.watch(cachedTileProviderProvider),
),
if (waypoints.length >= 2)
PolylineLayer(
polylines: [