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:
@@ -20,7 +20,7 @@ v3 held to.
|
|||||||
| # | Ticket | Size | Depends on | Status |
|
| # | 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-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-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-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 |
|
| [UI-05](UI-05-map-hud-record-screen.md) | Map HUD: Record screen redesign | M | UI-01, UI-03, UI-04 | Not started |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# UI-02 — Offline / no-connection skeleton map
|
# 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
|
## Goal
|
||||||
When the map has no tiles to show — no cached tiles for the current view and no network
|
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
|
## Out of scope
|
||||||
General offline-mode UX beyond the map itself (e.g. graying out map-dependent buttons).
|
General offline-mode UX beyond the map itself (e.g. graying out map-dependent buttons).
|
||||||
Manual retry controls — automatic recovery is the whole point.
|
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.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import 'dart:io';
|
|||||||
import 'package:drift/drift.dart' show driftRuntimeOptions;
|
import 'package:drift/drift.dart' show driftRuntimeOptions;
|
||||||
import 'package:drift_flutter/drift_flutter.dart';
|
import 'package:drift_flutter/drift_flutter.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
import '../config/config.dart';
|
import '../config/config.dart';
|
||||||
@@ -28,7 +29,9 @@ import '../recording/wakelock_controller.dart';
|
|||||||
import '../telemetry/live_telemetry.dart';
|
import '../telemetry/live_telemetry.dart';
|
||||||
import '../telemetry/telemetry_uploader.dart';
|
import '../telemetry/telemetry_uploader.dart';
|
||||||
import '../tiles/cached_tile_provider.dart';
|
import '../tiles/cached_tile_provider.dart';
|
||||||
|
import '../tiles/map_connectivity.dart';
|
||||||
import '../tiles/tile_cache.dart';
|
import '../tiles/tile_cache.dart';
|
||||||
|
import '../tiles/tile_config.dart';
|
||||||
|
|
||||||
/// The Drift database, opened against app-private storage.
|
/// The Drift database, opened against app-private storage.
|
||||||
///
|
///
|
||||||
@@ -222,7 +225,7 @@ final tileCacheProvider = FutureProvider<TileCache>((ref) async {
|
|||||||
final cachedTileProviderProvider = Provider<CachedTileProvider?>((ref) {
|
final cachedTileProviderProvider = Provider<CachedTileProvider?>((ref) {
|
||||||
final cache = ref.watch(tileCacheProvider).valueOrNull;
|
final cache = ref.watch(tileCacheProvider).valueOrNull;
|
||||||
if (cache == null) return null;
|
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]
|
/// 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);
|
final cache = await ref.watch(tileCacheProvider.future);
|
||||||
return cache.sizeBytes();
|
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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -11,17 +11,24 @@ import 'package:flutter/painting.dart';
|
|||||||
import 'package:flutter_map/flutter_map.dart';
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import 'map_connectivity.dart';
|
||||||
import 'tile_cache.dart';
|
import 'tile_cache.dart';
|
||||||
import 'tile_math.dart';
|
import 'tile_math.dart';
|
||||||
|
|
||||||
class CachedTileProvider extends TileProvider {
|
class CachedTileProvider extends TileProvider {
|
||||||
CachedTileProvider({required this.cache, http.Client? client})
|
CachedTileProvider({required this.cache, this.connectivity, http.Client? client})
|
||||||
: _client = client ?? http.Client(),
|
: _client = client ?? http.Client(),
|
||||||
super();
|
super();
|
||||||
|
|
||||||
final TileCache cache;
|
final TileCache cache;
|
||||||
final http.Client _client;
|
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
|
@override
|
||||||
ImageProvider getImage(TileCoordinates coordinates, TileLayer options) =>
|
ImageProvider getImage(TileCoordinates coordinates, TileLayer options) =>
|
||||||
_CacheBackedImage(
|
_CacheBackedImage(
|
||||||
@@ -30,6 +37,7 @@ class CachedTileProvider extends TileProvider {
|
|||||||
headers: headers,
|
headers: headers,
|
||||||
cache: cache,
|
cache: cache,
|
||||||
client: _client,
|
client: _client,
|
||||||
|
connectivity: connectivity,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,6 +48,7 @@ class _CacheBackedImage extends ImageProvider<_CacheBackedImage> {
|
|||||||
required this.headers,
|
required this.headers,
|
||||||
required this.cache,
|
required this.cache,
|
||||||
required this.client,
|
required this.client,
|
||||||
|
this.connectivity,
|
||||||
});
|
});
|
||||||
|
|
||||||
final TileKey key;
|
final TileKey key;
|
||||||
@@ -47,6 +56,7 @@ class _CacheBackedImage extends ImageProvider<_CacheBackedImage> {
|
|||||||
final Map<String, String> headers;
|
final Map<String, String> headers;
|
||||||
final TileCache cache;
|
final TileCache cache;
|
||||||
final http.Client client;
|
final http.Client client;
|
||||||
|
final MapConnectivityState? connectivity;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<_CacheBackedImage> obtainKey(ImageConfiguration configuration) =>
|
Future<_CacheBackedImage> obtainKey(ImageConfiguration configuration) =>
|
||||||
@@ -62,21 +72,32 @@ class _CacheBackedImage extends ImageProvider<_CacheBackedImage> {
|
|||||||
|
|
||||||
Future<ui.Codec> _load(ImageDecoderCallback decode) async {
|
Future<ui.Codec> _load(ImageDecoderCallback decode) async {
|
||||||
final cached = await cache.get(key);
|
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 bytes = cached ?? await _fetchAndStore();
|
||||||
final buffer = await ui.ImmutableBuffer.fromUint8List(bytes);
|
final buffer = await ui.ImmutableBuffer.fromUint8List(bytes);
|
||||||
return decode(buffer);
|
return decode(buffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Uint8List> _fetchAndStore() async {
|
Future<Uint8List> _fetchAndStore() async {
|
||||||
final response = await client.get(Uri.parse(url), headers: headers);
|
try {
|
||||||
if (response.statusCode != 200) {
|
final response = await client.get(Uri.parse(url), headers: headers);
|
||||||
throw Exception('Tile fetch failed: ${response.statusCode} for $url');
|
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
|
@override
|
||||||
|
|||||||
108
lib/src/tiles/map_connectivity.dart
Normal file
108
lib/src/tiles/map_connectivity.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
18
lib/src/tiles/tile_config.dart
Normal file
18
lib/src/tiles/tile_config.dart
Normal 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';
|
||||||
@@ -94,6 +94,7 @@ class ShellScaffold extends ConsumerWidget {
|
|||||||
fill: true,
|
fill: true,
|
||||||
showEmptyLabel: false,
|
showEmptyLabel: false,
|
||||||
tileProvider: ref.watch(cachedTileProviderProvider),
|
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 '../../domain/models.dart';
|
||||||
import '../../geo/geo.dart' as geo;
|
import '../../geo/geo.dart' as geo;
|
||||||
|
import '../../tiles/tile_config.dart';
|
||||||
import '../theme.dart' show ripprRadiusLarge;
|
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.
|
/// Metres. Render-only: a 3-hour ride is ~21,600 points and would jank undecimated.
|
||||||
const double simplifyEpsilonM = 5.0;
|
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.
|
/// fiddly, and flutter_map has no equivalent either. Buckets also read better at a glance.
|
||||||
const int speedBucketKmh = 10;
|
const int speedBucketKmh = 10;
|
||||||
|
|
||||||
/// This app's own zoom ceiling -- not raised to CARTO's native 20 (see
|
/// This app's own zoom ceiling -- not raised to CARTO's native 20 ([tileMaxNativeZoom])
|
||||||
/// [tileMaxNativeZoom]) without deliberately re-verifying the fit/follow-zoom logic
|
/// without deliberately re-verifying the fit/follow-zoom logic tuned against 19 by
|
||||||
/// tuned against 19 by earlier tickets. Exceeding it renders an empty grid.
|
/// earlier tickets. Exceeding it renders an empty grid.
|
||||||
const double maxTileZoom = 19.0;
|
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.
|
/// What a very short ride falls back to, so streets stay visible.
|
||||||
const double shortRideZoom = 17.0;
|
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 {
|
class RideMap extends StatefulWidget {
|
||||||
const RideMap({
|
const RideMap({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -65,12 +56,21 @@ class RideMap extends StatefulWidget {
|
|||||||
this.tileProvider,
|
this.tileProvider,
|
||||||
this.fill = false,
|
this.fill = false,
|
||||||
this.showEmptyLabel = true,
|
this.showEmptyLabel = true,
|
||||||
|
this.skeletonMode = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<TrackPoint> points;
|
final List<TrackPoint> points;
|
||||||
final List<Segment> segments;
|
final List<Segment> segments;
|
||||||
final double height;
|
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`
|
/// UI-01: fills whatever space the parent gives it (a `Positioned.fill`/`Expanded`
|
||||||
/// ancestor) instead of the fixed [height] -- for the persistent full-screen
|
/// ancestor) instead of the fixed [height] -- for the persistent full-screen
|
||||||
/// background map behind every tab, where there is no card to size it.
|
/// 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: [
|
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
|
// Omitted entirely while backgrounded -- not just visually hidden -- so no
|
||||||
// tile request can fire off-screen. See the lifecycle observer above.
|
// tile request can fire off-screen. See the lifecycle observer above.
|
||||||
if (!_backgrounded)
|
else if (!_backgrounded)
|
||||||
TileLayer(
|
TileLayer(
|
||||||
urlTemplate: tileUrlTemplate,
|
urlTemplate: tileUrlTemplate,
|
||||||
subdomains: tileSubdomains,
|
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,
|
points: detail.points,
|
||||||
segments: detail.segments,
|
segments: detail.segments,
|
||||||
tileProvider: ref.watch(cachedTileProviderProvider),
|
tileProvider: ref.watch(cachedTileProviderProvider),
|
||||||
|
skeletonMode: ref.watch(mapConnectivityProvider).skeletonMode,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (detail.mapEnabled) const SizedBox(height: 24),
|
if (detail.mapEnabled) const SizedBox(height: 24),
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import '../components/ride_map.dart'
|
|||||||
tileSubdomains,
|
tileSubdomains,
|
||||||
tileUrlTemplate,
|
tileUrlTemplate,
|
||||||
tileUserAgent;
|
tileUserAgent;
|
||||||
|
import '../components/skeleton_map_layer.dart';
|
||||||
import '../components/stats.dart' show confirmDialog;
|
import '../components/stats.dart' show confirmDialog;
|
||||||
import '../format.dart';
|
import '../format.dart';
|
||||||
|
|
||||||
@@ -149,15 +150,21 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||||||
repo.addWaypoint(widget.routeId, point.latitude, point.longitude),
|
repo.addWaypoint(widget.routeId, point.latitude, point.longitude),
|
||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
TileLayer(
|
// UI-02: same skeleton-or-tiles swap as RideMap's background usage --
|
||||||
urlTemplate: tileUrlTemplate,
|
// this screen manages its own FlutterMap directly rather than through
|
||||||
subdomains: tileSubdomains,
|
// RideMap, so it has to watch connectivity and swap the layer itself.
|
||||||
retinaMode: true,
|
if (ref.watch(mapConnectivityProvider).skeletonMode)
|
||||||
userAgentPackageName: tileUserAgent,
|
const SkeletonMapLayer()
|
||||||
maxNativeZoom: tileMaxNativeZoom,
|
else
|
||||||
panBuffer: 0,
|
TileLayer(
|
||||||
tileProvider: ref.watch(cachedTileProviderProvider),
|
urlTemplate: tileUrlTemplate,
|
||||||
),
|
subdomains: tileSubdomains,
|
||||||
|
retinaMode: true,
|
||||||
|
userAgentPackageName: tileUserAgent,
|
||||||
|
maxNativeZoom: tileMaxNativeZoom,
|
||||||
|
panBuffer: 0,
|
||||||
|
tileProvider: ref.watch(cachedTileProviderProvider),
|
||||||
|
),
|
||||||
if (waypoints.length >= 2)
|
if (waypoints.length >= 2)
|
||||||
PolylineLayer(
|
PolylineLayer(
|
||||||
polylines: [
|
polylines: [
|
||||||
|
|||||||
116
test/map_connectivity_test.dart
Normal file
116
test/map_connectivity_test.dart
Normal file
@@ -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<void>.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<void>.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');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import 'package:flutter_map/flutter_map.dart';
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:rippr/src/domain/models.dart';
|
import 'package:rippr/src/domain/models.dart';
|
||||||
import 'package:rippr/src/ui/components/ride_map.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';
|
import 'package:rippr/src/ui/theme.dart';
|
||||||
|
|
||||||
/// Tiles are never fetched here — a widget test cannot serve them — but the polyline
|
/// 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,
|
expect(map.options.maxZoom, maxTileZoom,
|
||||||
reason: 'exceeding OSM max tile zoom renders an empty grid');
|
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<PolylineLayer>(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');
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user