From 10040e698522096074c86d5b0a7133fb56b51a7a Mon Sep 17 00:00:00 2001 From: uhryniuk Date: Sun, 23 Aug 2026 20:07:47 -0500 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_012Xki7YAcc2TiN2PRZJ2tXr --- docs/ui-redesign/README.md | 2 +- .../ui-redesign/UI-02-offline-skeleton-map.md | 68 +++++++++- lib/src/app/providers.dart | 41 ++++++- lib/src/tiles/cached_tile_provider.dart | 39 ++++-- lib/src/tiles/map_connectivity.dart | 108 ++++++++++++++++ lib/src/tiles/tile_config.dart | 18 +++ lib/src/ui/app_shell.dart | 1 + lib/src/ui/components/ride_map.dart | 39 +++--- lib/src/ui/components/skeleton_map_layer.dart | 96 +++++++++++++++ lib/src/ui/detail/trip_detail_screen.dart | 1 + lib/src/ui/routes/route_planner_screen.dart | 25 ++-- test/map_connectivity_test.dart | 116 ++++++++++++++++++ test/ride_map_test.dart | 64 ++++++++++ 13 files changed, 580 insertions(+), 38 deletions(-) create mode 100644 lib/src/tiles/map_connectivity.dart create mode 100644 lib/src/tiles/tile_config.dart create mode 100644 lib/src/ui/components/skeleton_map_layer.dart create mode 100644 test/map_connectivity_test.dart diff --git a/docs/ui-redesign/README.md b/docs/ui-redesign/README.md index 68016cd..38364f8 100644 --- a/docs/ui-redesign/README.md +++ b/docs/ui-redesign/README.md @@ -20,7 +20,7 @@ v3 held to. | # | Ticket | Size | Depends on | Status | |---|---|---|---|---| | [UI-01](UI-01-tab-shell-background-map.md) | Persistent tab shell with an always-visible background map | L | — | Done | -| [UI-02](UI-02-offline-skeleton-map.md) | Offline / no-connection skeleton map | S | UI-01 | Not started | +| [UI-02](UI-02-offline-skeleton-map.md) | Offline / no-connection skeleton map | S | UI-01 | Done | | [UI-03](UI-03-glass-component-kit.md) | Shared floating-glass component kit | M | UI-08 | Not started | | [UI-04](UI-04-customizable-hud-widgets.md) | Customizable HUD telemetry widgets (drag, resize, visibility toggle) | L | UI-03 | Not started | | [UI-05](UI-05-map-hud-record-screen.md) | Map HUD: Record screen redesign | M | UI-01, UI-03, UI-04 | Not started | diff --git a/docs/ui-redesign/UI-02-offline-skeleton-map.md b/docs/ui-redesign/UI-02-offline-skeleton-map.md index 6af9df0..81ad726 100644 --- a/docs/ui-redesign/UI-02-offline-skeleton-map.md +++ b/docs/ui-redesign/UI-02-offline-skeleton-map.md @@ -1,6 +1,6 @@ # UI-02 — Offline / no-connection skeleton map -**Depends on** UI-01 · **Size** S · **Status** Not started +**Depends on** UI-01 · **Size** S · **Status** Done ## Goal When the map has no tiles to show — no cached tiles for the current view and no network @@ -80,3 +80,69 @@ own success/failure is the only thing that actually matters here. ## Out of scope General offline-mode UX beyond the map itself (e.g. graying out map-dependent buttons). Manual retry controls — automatic recovery is the whole point. + +## Outcome + +Built exactly to the ticket's Design section. `lib/src/tiles/map_connectivity.dart`'s +`MapConnectivityState` tracks consecutive tile-fetch failures (`skeletonFailureThreshold += 3`), flips to skeleton mode on the threshold, and recovers instantly on a single +success — either an ordinary fetch succeeding (while still below threshold, resetting +the run) or, once already in skeleton mode, a periodic single-tile probe +(`skeletonProbeInterval = 15s`) succeeding. The probe is a deliberate design choice: once +skeleton mode starts, `RideMap`/the route planner fully unmount their `TileLayer` (no +ongoing requests at all, per the ticket's "never a widget that itself keeps trying and +failing in a loop"), so nothing generates ordinary fetch outcomes to recover from — the +probe exists specifically to test the water on the map's behalf, at a low, fixed +cadence, using one deterministic tile (the zoom-0 whole-world overview) rather than +whatever happened to be in view. + +`CachedTileProvider` (`lib/src/tiles/cached_tile_provider.dart`) now takes an optional +`MapConnectivityState? connectivity` and reports outcomes around its network fetch only +— a cache hit is silently skipped since it says nothing about current connectivity +either way, matching the ticket's "cache miss *and* network fetch failed" definition of +a failure exactly. `mapConnectivityProvider` (`app/providers.dart`) is the one shared +instance every map watches, per the class's own reasoning: connectivity is a fact about +the network, not about which map widget happens to be on screen. + +`SkeletonMapLayer` (`lib/src/ui/components/skeleton_map_layer.dart`) is the 40px faint +grid (reusing the Stitch exports' `.map-grid-overlay` treatment directly, `rgba(255,255, +255,0.03-0.05)` → `Color(0x0DFFFFFF)`) with a `LinearGradient` shimmer sweeping across it +on a 1800ms repeating cycle, painted via `ShaderMask`/`CustomPainter` rather than a +translated widget so it doesn't need to know its own pixel size. `RideMap` gained a +`skeletonMode` bool (a plain constructor param, not a `ConsumerWidget` watch — consistent +with how `tileProvider` is already handed down rather than looked up) that swaps +`TileLayer` for `SkeletonMapLayer` while leaving `PolylineLayer`/markers untouched, since +those come from local data. The route planner manages its own independent `FlutterMap` +and does the same swap itself, watching `mapConnectivityProvider` directly. + +**Refactor along the way:** moved `tileUrlTemplate`/`tileSubdomains`/`tileMaxNativeZoom`/ +`tileUserAgent` out of `ride_map.dart` into a new `lib/src/tiles/tile_config.dart`, and +had `ride_map.dart` re-export them for its existing importers. `app/providers.dart` (the +composition root) needed these constants for the connectivity probe's URL, and importing +a `ui/` file from the `app/` layer would have been a real layering violation the tiles +layer itself doesn't have a reason to accept — better to give the constants a home in +the layer they actually describe (tile fetching) than to route around the smell. + +**Bug caught by the first real test run:** `mapConnectivityProvider`'s initial +implementation called `ref.onDispose(state.dispose)` in addition to +`ChangeNotifierProvider`'s own automatic disposal of the notifier it returns — a +double-dispose that threw "A MapConnectivityState was used after being disposed" and +failed five `route_planner_screen_test.dart` tests outright. Fixed by removing the +redundant manual dispose call. + +**Tests:** `flutter analyze` clean. `flutter test` green at 328 tests (316 + 3 new +`ride_map_test.dart` skeleton-mode cases + 9 new `map_connectivity_test.dart` unit cases +covering threshold behavior, the reset-on-success case, notification-only-on-actual- +change, and the probe loop's recovery and its refusal to run at all while still live). + +**Android emulator verification** (`Medium_Phone_API_35`) — a full end-to-end real- +network test, not just widget tests: cut the emulator's actual network +(`svc wifi disable` + `svc data disable`, confirmed via a failing `ping`), cleared the +offline tile cache via Settings, and navigated to an uncached view (the route planner's +default zoom-14 view, which the shell's already-cached Calgary-area background tiles +didn't cover). After the threshold of real failed fetches, the skeleton grid rendered +correctly — clearly a "still loading" placeholder, not blank space or broken-image +icons. Re-enabled the network and confirmed automatic recovery within one probe interval +with no user action, exactly per the acceptance criteria. The shared shell background +map (already displaying previously-cached tiles from memory) was unaffected throughout, +as expected since it never needed a fresh fetch during the test window. diff --git a/lib/src/app/providers.dart b/lib/src/app/providers.dart index ec268fb..0a77383 100644 --- a/lib/src/app/providers.dart +++ b/lib/src/app/providers.dart @@ -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((ref) async { final cachedTileProviderProvider = Provider((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((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((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; + } + }, + ); +}); diff --git a/lib/src/tiles/cached_tile_provider.dart b/lib/src/tiles/cached_tile_provider.dart index d2939cb..8bdd3ec 100644 --- a/lib/src/tiles/cached_tile_provider.dart +++ b/lib/src/tiles/cached_tile_provider.dart @@ -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 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 _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 _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 diff --git a/lib/src/tiles/map_connectivity.dart b/lib/src/tiles/map_connectivity.dart new file mode 100644 index 0000000..6074c89 --- /dev/null +++ b/lib/src/tiles/map_connectivity.dart @@ -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 Function() probe, Duration? probeInterval}) + : _probe = probe, + _probeInterval = probeInterval ?? skeletonProbeInterval; + + final Future 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 _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(); + } +} diff --git a/lib/src/tiles/tile_config.dart b/lib/src/tiles/tile_config.dart new file mode 100644 index 0000000..806344b --- /dev/null +++ b/lib/src/tiles/tile_config.dart @@ -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 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'; diff --git a/lib/src/ui/app_shell.dart b/lib/src/ui/app_shell.dart index a4fb9a2..fcb30ec 100644 --- a/lib/src/ui/app_shell.dart +++ b/lib/src/ui/app_shell.dart @@ -94,6 +94,7 @@ class ShellScaffold extends ConsumerWidget { fill: true, showEmptyLabel: false, tileProvider: ref.watch(cachedTileProviderProvider), + skeletonMode: ref.watch(mapConnectivityProvider).skeletonMode, ), ), ), diff --git a/lib/src/ui/components/ride_map.dart b/lib/src/ui/components/ride_map.dart index 5c55d61..122d880 100644 --- a/lib/src/ui/components/ride_map.dart +++ b/lib/src/ui/components/ride_map.dart @@ -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 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 points; final List 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 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, diff --git a/lib/src/ui/components/skeleton_map_layer.dart b/lib/src/ui/components/skeleton_map_layer.dart new file mode 100644 index 0000000..ba5d6d1 --- /dev/null +++ b/lib/src/ui/components/skeleton_map_layer.dart @@ -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 createState() => _SkeletonMapLayerState(); +} + +class _SkeletonMapLayerState extends State + 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; +} diff --git a/lib/src/ui/detail/trip_detail_screen.dart b/lib/src/ui/detail/trip_detail_screen.dart index c6afe6d..7e08ccf 100644 --- a/lib/src/ui/detail/trip_detail_screen.dart +++ b/lib/src/ui/detail/trip_detail_screen.dart @@ -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), diff --git a/lib/src/ui/routes/route_planner_screen.dart b/lib/src/ui/routes/route_planner_screen.dart index 4a6e3fb..17d8f0b 100644 --- a/lib/src/ui/routes/route_planner_screen.dart +++ b/lib/src/ui/routes/route_planner_screen.dart @@ -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 { 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: [ diff --git a/test/map_connectivity_test.dart b/test/map_connectivity_test.dart new file mode 100644 index 0000000..56dc944 --- /dev/null +++ b/test/map_connectivity_test.dart @@ -0,0 +1,116 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:rippr/src/tiles/map_connectivity.dart'; + +/// UI-02: exercises the failure-count/threshold/probe logic with a fake probe -- no +/// real network -- mirroring V3-11's `tile_downloader_test.dart` pattern of injecting +/// fake fetch outcomes rather than hitting a real tile host from a test. +void main() { + late bool probeResult; + late int probeCalls; + late MapConnectivityState state; + + setUp(() { + probeResult = false; + probeCalls = 0; + state = MapConnectivityState( + probe: () async { + probeCalls++; + return probeResult; + }, + probeInterval: const Duration(milliseconds: 20), + ); + }); + + tearDown(() { + state.dispose(); + }); + + test('starts in live mode', () { + expect(state.skeletonMode, isFalse); + }); + + test('fewer than the threshold of failures stays live', () { + state.reportFailure(); + state.reportFailure(); + expect(state.skeletonMode, isFalse); + }); + + test('reaching the threshold of consecutive failures switches to skeleton mode', () { + for (var i = 0; i < skeletonFailureThreshold; i++) { + state.reportFailure(); + } + expect(state.skeletonMode, isTrue); + }); + + test('a success before the threshold resets the failure count', () { + state.reportFailure(); + state.reportFailure(); + state.reportSuccess(); + state.reportFailure(); + state.reportFailure(); + expect(state.skeletonMode, isFalse, + reason: 'the success should have reset the run -- two more failures is not ' + 'the same as four in a row'); + }); + + test('notifies listeners exactly when skeletonMode actually changes', () { + var notifications = 0; + state.addListener(() => notifications++); + + state.reportFailure(); + state.reportFailure(); + expect(notifications, 0, reason: 'still below threshold -- no state change yet'); + + state.reportFailure(); + expect(notifications, 1); + expect(state.skeletonMode, isTrue); + }); + + test('a single success while already in skeleton mode recovers immediately', () { + for (var i = 0; i < skeletonFailureThreshold; i++) { + state.reportFailure(); + } + expect(state.skeletonMode, isTrue); + + state.reportSuccess(); + expect(state.skeletonMode, isFalse); + }); + + test('further failures are ignored once already in skeleton mode', () { + for (var i = 0; i < skeletonFailureThreshold; i++) { + state.reportFailure(); + } + var notifications = 0; + state.addListener(() => notifications++); + + // Recovery is the probe loop's job from here -- more failure reports (e.g. from a + // TileLayer that hadn't yet unmounted) must not do anything further. + state.reportFailure(); + state.reportFailure(); + + expect(notifications, 0); + expect(state.skeletonMode, isTrue); + }); + + test('the probe loop recovers automatically once it starts succeeding', () async { + for (var i = 0; i < skeletonFailureThreshold; i++) { + state.reportFailure(); + } + expect(state.skeletonMode, isTrue); + + probeResult = true; + // Give the periodic probe timer a couple of intervals to fire. + await Future.delayed(const Duration(milliseconds: 60)); + + expect(state.skeletonMode, isFalse); + expect(probeCalls, greaterThan(0)); + }); + + test('the probe loop does not run while already live', () async { + await Future.delayed(const Duration(milliseconds: 60)); + expect(probeCalls, 0, + reason: 'a TileLayer that is live and working generates its own fetch ' + 'reports -- a periodic probe on top of that would be exactly the ' + 'excess-request behaviour this ticket exists to avoid'); + }); +} diff --git a/test/ride_map_test.dart b/test/ride_map_test.dart index 2ff907f..969b07e 100644 --- a/test/ride_map_test.dart +++ b/test/ride_map_test.dart @@ -3,6 +3,7 @@ import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:rippr/src/domain/models.dart'; import 'package:rippr/src/ui/components/ride_map.dart'; +import 'package:rippr/src/ui/components/skeleton_map_layer.dart'; import 'package:rippr/src/ui/theme.dart'; /// Tiles are never fetched here — a widget test cannot serve them — but the polyline @@ -117,4 +118,67 @@ void main() { expect(map.options.maxZoom, maxTileZoom, reason: 'exceeding OSM max tile zoom renders an empty grid'); }); + + group('skeleton mode (UI-02)', () { + testWidgets('shows the skeleton and no TileLayer when skeletonMode is true', + (tester) async { + await tester.pumpWidget(MaterialApp( + theme: ripprTheme(), + home: const Scaffold( + body: RideMap(points: [], segments: [], showEmptyLabel: false, skeletonMode: true), + ), + )); + await tester.pump(); + + expect(find.byType(SkeletonMapLayer), findsOneWidget); + expect(find.byType(TileLayer), findsNothing); + }); + + testWidgets('shows the TileLayer and no skeleton when skeletonMode is false', + (tester) async { + await tester.pumpWidget(MaterialApp( + theme: ripprTheme(), + home: const Scaffold( + body: RideMap(points: [], segments: [], showEmptyLabel: false), + ), + )); + await tester.pump(); + + expect(find.byType(TileLayer), findsOneWidget); + expect(find.byType(SkeletonMapLayer), findsNothing); + }); + + testWidgets('the recorded path still renders on top of the skeleton', + (tester) async { + final points = [ + for (var i = 0; i < 4; i++) + TrackPoint( + id: i + 1, + tripId: 1, + segmentId: 1, + timestamp: 1000 + i * 1000, + latitude: 51.0 + i * 0.0005, + longitude: -114.0, + speedKmh: 40, + altitudeM: 1000, + ), + ]; + const segments = [Segment(id: 1, tripId: 1, startedAt: 0, endedAt: 1)]; + + await tester.pumpWidget(MaterialApp( + theme: ripprTheme(), + home: Scaffold( + body: RideMap(points: points, segments: segments, skeletonMode: true), + ), + )); + await tester.pump(); + + expect(find.byType(SkeletonMapLayer), findsOneWidget); + expect(find.byType(TileLayer), findsNothing); + final layer = tester.widget(find.byType(PolylineLayer)); + expect(layer.polylines, isNotEmpty, + reason: 'the path comes from local data, not tiles -- it must not vanish ' + 'just because the tile fetch is failing'); + }); + }); }