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:
tajniak81andClaude Opus 5.5 committed 2026-10-21 21:35:19 +02:00
1 parent cfab444c65
commit 91520560a7
5 files changed
+79 -19

No files matched your search

+1 -1
View File
@@ -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>[
+1 -1
View File
@@ -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))),
+1 -1
View File
@@ -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, _) {
+47 -16
View File
@@ -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();
+29
View File
@@ -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());
});
}