Retry map tiles that failed while they are still on screen
The previous fix silenced tile errors, which made flutter_map count a failed tile as a loaded transparent one, so it was never fetched again. Errors now reach the layer, and OsmTileLayer rebuilds itself a few seconds after a failure: loaded tiles come back from the image cache and only the failed ones are requested again. Seen on the tablet, whose 2.4 GHz Wi-Fi loses a fifth of its packets next to the OcuSync remote. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cfab444c65
commit
91520560a7
5 files changed
+79
-19
No files matched your search
@@ -137,7 +137,7 @@ class _FindMyDronePageState extends State<FindMyDronePage> {
|
||||
? MapOptions(initialCameraFit: CameraFit.bounds(bounds: LatLngBounds.fromPoints(fit), padding: const EdgeInsets.all(48), maxZoom: 18))
|
||||
: MapOptions(initialCenter: drone, initialZoom: 17),
|
||||
children: <Widget>[
|
||||
osmTileLayer(),
|
||||
const OsmTileLayer(),
|
||||
if (path.length >= 2) PolylineLayer(polylines: <Polyline>[Polyline(points: path, strokeWidth: 2.5, color: const Color(0x993D7BF0))]),
|
||||
if (_phone != null)
|
||||
PolylineLayer(polylines: <Polyline>[
|
||||
|
||||
@@ -349,7 +349,7 @@ class FlightTrackMap extends StatelessWidget {
|
||||
return FlutterMap(
|
||||
options: options,
|
||||
children: <Widget>[
|
||||
osmTileLayer(),
|
||||
const OsmTileLayer(),
|
||||
if (pts.length >= 2) PolylineLayer(polylines: <Polyline>[Polyline(points: pts, strokeWidth: 3, color: const Color(0xFF3D7BF0))]),
|
||||
MarkerLayer(markers: <Marker>[
|
||||
Marker(point: pts.first, width: 14, height: 14, child: _dot(const Color(0xFF2FA36F))),
|
||||
|
||||
@@ -140,7 +140,7 @@ class _FlyMapState extends State<FlyMap> {
|
||||
onTap: widget.interactive && widget.onTap != null ? (TapPosition _, LatLng at) => widget.onTap!(at) : null,
|
||||
),
|
||||
children: <Widget>[
|
||||
osmTileLayer(),
|
||||
const OsmTileLayer(),
|
||||
ListenableBuilder(
|
||||
listenable: Listenable.merge(<Listenable>[widget.trail, FlyPrefs.showRoute]),
|
||||
builder: (BuildContext context, _) {
|
||||
|
||||
@@ -1,30 +1,61 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:http/http.dart' show Client;
|
||||
import 'package:http/retry.dart';
|
||||
|
||||
/// The OpenStreetMap base layer for every map in the app.
|
||||
///
|
||||
/// flutter_map's own client only retries a 503, and a tile that failed once
|
||||
/// stays blank until it is pruned: on the tablet's flaky Wi-Fi that left dark
|
||||
/// squares on the map. This client also retries dropped connections, and a
|
||||
/// failed tile is dropped once off screen so panning back fetches it again.
|
||||
TileLayer osmTileLayer() => TileLayer(
|
||||
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName: 'com.pilotvault.flyapp',
|
||||
tileProvider: _OsmTileProvider.instance,
|
||||
evictErrorTileStrategy: EvictErrorTileStrategy.notVisibleRespectMargin,
|
||||
);
|
||||
/// flutter_map never fetches a failed tile again while it stays on screen,
|
||||
/// and on a bad link (the tablet's 2.4 GHz Wi-Fi next to an OcuSync remote)
|
||||
/// that left dark squares on the map. So the client also retries dropped
|
||||
/// connections, and a failure rebuilds the layer a little later: tiles that
|
||||
/// loaded come back from the image cache, the failed ones are fetched again.
|
||||
class OsmTileLayer extends StatefulWidget {
|
||||
const OsmTileLayer({super.key, this.retryAfter = const Duration(seconds: 5)});
|
||||
|
||||
final Duration retryAfter;
|
||||
|
||||
@override
|
||||
State<OsmTileLayer> createState() => _OsmTileLayerState();
|
||||
}
|
||||
|
||||
class _OsmTileLayerState extends State<OsmTileLayer> {
|
||||
int _generation = 0;
|
||||
Timer? _retry;
|
||||
|
||||
void _onError(TileImage _, Object __, StackTrace? ___) {
|
||||
_retry ??= Timer(widget.retryAfter, () {
|
||||
_retry = null;
|
||||
if (mounted) setState(() => _generation++);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_retry?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => TileLayer(
|
||||
key: ValueKey<int>(_generation),
|
||||
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName: 'com.pilotvault.flyapp',
|
||||
tileProvider: _OsmTileProvider.instance,
|
||||
evictErrorTileStrategy: EvictErrorTileStrategy.notVisibleRespectMargin,
|
||||
errorTileCallback: _onError,
|
||||
);
|
||||
}
|
||||
|
||||
/// One provider for the app's lifetime, so rebuilds hand every map the same
|
||||
/// instance (a new one makes the layer reload all its tiles). A provider
|
||||
/// normally closes its client when its layer goes; this one is shared, so it
|
||||
/// keeps the client open.
|
||||
/// keeps the client open. Errors are not silenced: a silenced failure counts
|
||||
/// as a loaded (transparent) tile and is never fetched again.
|
||||
class _OsmTileProvider extends NetworkTileProvider {
|
||||
_OsmTileProvider()
|
||||
: super(
|
||||
httpClient: RetryClient(Client(), whenError: (Object _, StackTrace __) => true),
|
||||
silenceExceptions: true,
|
||||
);
|
||||
_OsmTileProvider() : super(httpClient: RetryClient(Client(), whenError: (Object _, StackTrace __) => true));
|
||||
|
||||
static final _OsmTileProvider instance = _OsmTileProvider();
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:latlong2/latlong.dart' show LatLng;
|
||||
import 'package:pilotvault_fly/ui/osm_tiles.dart';
|
||||
|
||||
void main() {
|
||||
// Every request fails in widget tests (the test HttpClient answers 400),
|
||||
// which is the flaky link this layer has to recover from.
|
||||
testWidgets('a failed tile rebuilds the layer after the retry delay', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: FlutterMap(
|
||||
options: const MapOptions(initialCenter: LatLng(55.25, 12.03), initialZoom: 15),
|
||||
children: const <Widget>[OsmTileLayer(retryAfter: Duration(seconds: 2))],
|
||||
),
|
||||
));
|
||||
Key? layerKey() => tester.widget<TileLayer>(find.byType(TileLayer)).key;
|
||||
final Key? first = layerKey();
|
||||
|
||||
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 500)));
|
||||
await tester.pump();
|
||||
expect(layerKey(), first, reason: 'nothing retries before the delay');
|
||||
|
||||
await tester.pump(const Duration(seconds: 2));
|
||||
expect(layerKey(), isNot(first));
|
||||
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user