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:
@@ -94,6 +94,7 @@ class ShellScaffold extends ConsumerWidget {
|
||||
fill: true,
|
||||
showEmptyLabel: false,
|
||||
tileProvider: ref.watch(cachedTileProviderProvider),
|
||||
skeletonMode: ref.watch(mapConnectivityProvider).skeletonMode,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
96
lib/src/ui/components/skeleton_map_layer.dart
Normal file
96
lib/src/ui/components/skeleton_map_layer.dart
Normal 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;
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user