From 91520560a79e7d16b7fd504740da1fb5f271bf3c Mon Sep 17 00:00:00 2001 From: tajniak81 <13187254+tajniak81@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:35:19 +0200 Subject: [PATCH] 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 --- Fly App/lib/ui/find_my_drone_page.dart | 2 +- Fly App/lib/ui/flight_logs_page.dart | 2 +- Fly App/lib/ui/fly/bottom/minimap.dart | 2 +- Fly App/lib/ui/osm_tiles.dart | 63 +++++++++++++++++++------- Fly App/test/fly/osm_tiles_test.dart | 29 ++++++++++++ 5 files changed, 79 insertions(+), 19 deletions(-) create mode 100644 Fly App/test/fly/osm_tiles_test.dart diff --git a/Fly App/lib/ui/find_my_drone_page.dart b/Fly App/lib/ui/find_my_drone_page.dart index c6961af..efcf2a8 100644 --- a/Fly App/lib/ui/find_my_drone_page.dart +++ b/Fly App/lib/ui/find_my_drone_page.dart @@ -137,7 +137,7 @@ class _FindMyDronePageState extends State { ? MapOptions(initialCameraFit: CameraFit.bounds(bounds: LatLngBounds.fromPoints(fit), padding: const EdgeInsets.all(48), maxZoom: 18)) : MapOptions(initialCenter: drone, initialZoom: 17), children: [ - osmTileLayer(), + const OsmTileLayer(), if (path.length >= 2) PolylineLayer(polylines: [Polyline(points: path, strokeWidth: 2.5, color: const Color(0x993D7BF0))]), if (_phone != null) PolylineLayer(polylines: [ diff --git a/Fly App/lib/ui/flight_logs_page.dart b/Fly App/lib/ui/flight_logs_page.dart index 3d59768..3dbf6c1 100644 --- a/Fly App/lib/ui/flight_logs_page.dart +++ b/Fly App/lib/ui/flight_logs_page.dart @@ -349,7 +349,7 @@ class FlightTrackMap extends StatelessWidget { return FlutterMap( options: options, children: [ - osmTileLayer(), + const OsmTileLayer(), if (pts.length >= 2) PolylineLayer(polylines: [Polyline(points: pts, strokeWidth: 3, color: const Color(0xFF3D7BF0))]), MarkerLayer(markers: [ Marker(point: pts.first, width: 14, height: 14, child: _dot(const Color(0xFF2FA36F))), diff --git a/Fly App/lib/ui/fly/bottom/minimap.dart b/Fly App/lib/ui/fly/bottom/minimap.dart index e89f670..469e27d 100644 --- a/Fly App/lib/ui/fly/bottom/minimap.dart +++ b/Fly App/lib/ui/fly/bottom/minimap.dart @@ -140,7 +140,7 @@ class _FlyMapState extends State { onTap: widget.interactive && widget.onTap != null ? (TapPosition _, LatLng at) => widget.onTap!(at) : null, ), children: [ - osmTileLayer(), + const OsmTileLayer(), ListenableBuilder( listenable: Listenable.merge([widget.trail, FlyPrefs.showRoute]), builder: (BuildContext context, _) { diff --git a/Fly App/lib/ui/osm_tiles.dart b/Fly App/lib/ui/osm_tiles.dart index 5cb112d..6c461f1 100644 --- a/Fly App/lib/ui/osm_tiles.dart +++ b/Fly App/lib/ui/osm_tiles.dart @@ -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 createState() => _OsmTileLayerState(); +} + +class _OsmTileLayerState extends State { + 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(_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(); diff --git a/Fly App/test/fly/osm_tiles_test.dart b/Fly App/test/fly/osm_tiles_test.dart new file mode 100644 index 0000000..999e211 --- /dev/null +++ b/Fly App/test/fly/osm_tiles_test.dart @@ -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 [OsmTileLayer(retryAfter: Duration(seconds: 2))], + ), + )); + Key? layerKey() => tester.widget(find.byType(TileLayer)).key; + final Key? first = layerKey(); + + await tester.runAsync(() => Future.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()); + }); +}