From cfab444c65b8dc4c52966bc639f6ad2634c78121 Mon Sep 17 00:00:00 2001 From: tajniak81 <13187254+tajniak81@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:13:24 +0200 Subject: [PATCH] Fix what the first secondary-screen run on the Mavic Pro turned up - Leaving the Album put the camera in video mode even when it had been in photo mode: it restored FlightModel.captureMode, which only follows the HUD switch. It now restores the mode the camera reported before it was taken. - Playing a video on the aircraft drew the player over the whole HUD. The Album now sets FlightModel.hudHidden, and the flight screen shows only the feed, full-screen, while it is set (the HUD stays mounted, offstage). - Map tiles that failed once stayed as dark squares. Every map now uses one OpenStreetMap provider whose client also retries dropped connections, and failed tiles are fetched again when panned back into view. - Find My Drone saved simulated positions as the aircraft's last fix. Co-Authored-By: Claude Opus 5.5 --- Fly App/lib/flight_model.dart | 6 +++++ Fly App/lib/fly/find_my_drone.dart | 4 ++- Fly App/lib/ui/album_page.dart | 10 +++++++- Fly App/lib/ui/find_my_drone_page.dart | 3 ++- Fly App/lib/ui/flight_logs_page.dart | 3 ++- Fly App/lib/ui/fly/bottom/minimap.dart | 6 ++--- Fly App/lib/ui/fly/fly_screen.dart | 18 +++++++++++--- Fly App/lib/ui/osm_tiles.dart | 33 +++++++++++++++++++++++++ Fly App/pubspec.lock | 2 +- Fly App/pubspec.yaml | 2 ++ Fly App/test/fly/hud_test.dart | 17 +++++++++++++ Fly App/test/fly/p9_secondary_test.dart | 18 ++++++++++++++ 12 files changed, 110 insertions(+), 12 deletions(-) create mode 100644 Fly App/lib/ui/osm_tiles.dart diff --git a/Fly App/lib/flight_model.dart b/Fly App/lib/flight_model.dart index 722f6f5..ed6c9f4 100644 --- a/Fly App/lib/flight_model.dart +++ b/Fly App/lib/flight_model.dart @@ -174,6 +174,11 @@ class FlightModel extends ChangeNotifier { /// The aircraft's video player while a video plays from the SD card (plan P9). final ValueNotifier mediaPlayback = ValueNotifier(const MediaPlayback()); + /// Set while a page over the flight screen shows the video feed on its own + /// (the Album playing a file on the aircraft): the flight screen then draws + /// the feed full-screen with no HUD on it. + final ValueNotifier hudHidden = ValueNotifier(false); + /// The live feed's picture size in pixels, once the decoder reports it: the /// native view letterboxes to it, and the touch layers map taps into it. final ValueNotifier videoSize = ValueNotifier(null); @@ -316,6 +321,7 @@ class FlightModel extends ChangeNotifier { } videoSize.dispose(); mediaPlayback.dispose(); + hudHidden.dispose(); rcButton.dispose(); rcMasters.dispose(); spectrum.dispose(); diff --git a/Fly App/lib/fly/find_my_drone.dart b/Fly App/lib/fly/find_my_drone.dart index 7ed5198..907b04b 100644 --- a/Fly App/lib/fly/find_my_drone.dart +++ b/Fly App/lib/fly/find_my_drone.dart @@ -127,7 +127,9 @@ class AircraftLocator extends ChangeNotifier { void _onAircraft() { final AircraftState a = model.aircraft; - if (!model.connected || !validFix(a.latitude, a.longitude)) return; + // A simulated position is not where the aircraft is, so it never replaces + // the last real fix. + if (!model.connected || a.simulatorActive == true || !validFix(a.latitude, a.longitude)) return; final DateTime now = _clock(); _last = LastKnownPosition( latitude: a.latitude!, diff --git a/Fly App/lib/ui/album_page.dart b/Fly App/lib/ui/album_page.dart index e47921e..d6b98fd 100644 --- a/Fly App/lib/ui/album_page.dart +++ b/Fly App/lib/ui/album_page.dart @@ -83,21 +83,27 @@ class _AlbumPageState extends State { /// Whether this page put the camera in download mode (and must put it back). bool _tookCamera = false; + /// The shooting mode to put back: the camera's own report from before this + /// page took it, since [FlightModel.captureMode] only follows the HUD switch. + late final bool _wasVideo; + DjiService get _dji => widget.dji; FlightModel get _m => widget.model; @override void initState() { super.initState(); + _wasVideo = _m.camera.mode == null ? _m.captureMode == CaptureMode.video : _m.camera.isVideoMode; _refresh(); } @override void dispose() { + _m.hudHidden.value = false; if (_tookCamera && _m.connected) { // Back to shooting: the flight screen's shutter needs its mode back. if (_playing != null) _dji.stopMediaVideo().catchError((Object _) {}); - _dji.setCameraMode(_m.captureMode == CaptureMode.video ? 'video' : 'photo').catchError((Object _) {}); + _dji.setCameraMode(_wasVideo ? 'video' : 'photo').catchError((Object _) {}); } super.dispose(); } @@ -365,6 +371,7 @@ class _AlbumPageState extends State { try { await _dji.playMediaVideo(item.index); if (mounted) setState(() => _playing = item); + _m.hudHidden.value = mounted; } catch (e) { if (mounted) _snack('Playback: ${_reason(e)}'); } @@ -446,6 +453,7 @@ class _AlbumPageState extends State { Future _closePlayer() async { await _transport(_dji.stopMediaVideo, 'Stop'); if (mounted) setState(() => _playing = null); + _m.hudHidden.value = false; } Widget _player(MediaItem item) { diff --git a/Fly App/lib/ui/find_my_drone_page.dart b/Fly App/lib/ui/find_my_drone_page.dart index cceabe2..c6961af 100644 --- a/Fly App/lib/ui/find_my_drone_page.dart +++ b/Fly App/lib/ui/find_my_drone_page.dart @@ -14,6 +14,7 @@ import '../fly/flight_record.dart'; import '../theme.dart'; import 'flight_logs_page.dart' show PageHeader; import 'pv_icons.dart'; +import 'osm_tiles.dart'; /// Find My Drone (plan P9): where the aircraft was last seen — live while it is /// connected — against this phone's own precise position, with the distance, @@ -136,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: [ - TileLayer(urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', userAgentPackageName: 'com.pilotvault.flyapp'), + 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 a52817b..3d59768 100644 --- a/Fly App/lib/ui/flight_logs_page.dart +++ b/Fly App/lib/ui/flight_logs_page.dart @@ -11,6 +11,7 @@ import '../pb_auth.dart'; import '../theme.dart'; import 'find_my_drone_page.dart'; import 'pv_icons.dart'; +import 'osm_tiles.dart'; /// Flight records (plan P9): every flight the app recorded, newest first, with /// its logbook upload state, and Find My Drone at the top. @@ -348,7 +349,7 @@ class FlightTrackMap extends StatelessWidget { return FlutterMap( options: options, children: [ - TileLayer(urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', userAgentPackageName: 'com.pilotvault.flyapp'), + 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 3772150..e89f670 100644 --- a/Fly App/lib/ui/fly/bottom/minimap.dart +++ b/Fly App/lib/ui/fly/bottom/minimap.dart @@ -11,6 +11,7 @@ import '../../../fly/state/aircraft_state.dart'; import '../../../theme.dart'; import '../../pv_icons.dart'; import '../hud_kit.dart'; +import '../../osm_tiles.dart'; /// What the minimap shows and how it follows the aircraft. Shared between the /// map layer and its HUD controls, which live in different parts of the tree. @@ -139,10 +140,7 @@ class _FlyMapState extends State { onTap: widget.interactive && widget.onTap != null ? (TapPosition _, LatLng at) => widget.onTap!(at) : null, ), children: [ - TileLayer( - urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', - userAgentPackageName: 'com.pilotvault.flyapp', - ), + osmTileLayer(), ListenableBuilder( listenable: Listenable.merge([widget.trail, FlyPrefs.showRoute]), builder: (BuildContext context, _) { diff --git a/Fly App/lib/ui/fly/fly_screen.dart b/Fly App/lib/ui/fly/fly_screen.dart index 03decd4..cb68a5a 100644 --- a/Fly App/lib/ui/fly/fly_screen.dart +++ b/Fly App/lib/ui/fly/fly_screen.dart @@ -199,6 +199,7 @@ class _FlyScreenState extends State { _seenModeFinishes = _m.mission.modeFinishes; _m.mission.addListener(_onMission); _m.aircraft.addListener(_onSticksAircraft); + _m.hudHidden.addListener(_onHudHidden); _m.aircraft.addListener(_onCacheAircraft); FlyPrefs.videoCache.addListener(_onCacheAircraft); _m.rcButton.addListener(_onRcButton); @@ -549,6 +550,7 @@ class _FlyScreenState extends State { @override void dispose() { _m.removeListener(_onModel); + _m.hudHidden.removeListener(_onHudHidden); _m.mission.removeListener(_onMission); _m.aircraft.removeListener(_onSticksAircraft); _m.aircraft.removeListener(_onCacheAircraft); @@ -584,9 +586,14 @@ class _FlyScreenState extends State { void _push(Widget page) => Navigator.of(context).push(MaterialPageRoute(builder: (_) => page)); + void _onHudHidden() { + if (mounted) setState(() {}); + } + @override Widget build(BuildContext context) { final MediaQueryData mq = MediaQuery.of(context); + final bool bare = _m.hudHidden.value; final double s = HudScale.scaleFor(mq.size.shortestSide); final Rect area = HudCanvas.screenRect(mq); final double canvasHeight = area.height / s; @@ -599,7 +606,7 @@ class _FlyScreenState extends State { listenable: _m, builder: (BuildContext context, _) => VideoLayer(connected: _m.connected), ); - final Widget map = FlyMap( + final Widget mapView = FlyMap( key: const ValueKey('map'), model: _m, trail: _trail, @@ -622,11 +629,15 @@ class _FlyScreenState extends State { } }, ); + final Widget map = Offstage(offstage: bare, child: mapView); final Widget body = Stack(fit: StackFit.expand, children: [ // The background view, then whatever sits in the corner box. Keyed, so // the swap moves the native video view instead of recreating it. - if (!_mapBig) ...[ + if (bare) ...[ + Positioned.fromRect(key: const ValueKey('video-slot'), rect: full, child: video), + Positioned.fromRect(key: const ValueKey('map-slot'), rect: pip, child: map), + ] else if (!_mapBig) ...[ Positioned.fromRect(key: const ValueKey('video-slot'), rect: full, child: video), Positioned.fill(child: VisualOverlays(video: _m.videoSize)), // A vision mode owns every touch on the video: a box or a point. @@ -687,7 +698,8 @@ class _FlyScreenState extends State { Positioned.fromRect(key: const ValueKey('map-slot'), rect: full, child: map), Positioned.fromRect(key: const ValueKey('video-slot'), rect: pip, child: video), ], - Positioned.fill(child: HudCanvas(builder: _hud)), + // Offstage rather than removed, so the HUD's own state survives. + Positioned.fill(child: Offstage(offstage: bare, child: HudCanvas(builder: _hud))), Positioned( left: 0, right: 0, diff --git a/Fly App/lib/ui/osm_tiles.dart b/Fly App/lib/ui/osm_tiles.dart new file mode 100644 index 0000000..5cb112d --- /dev/null +++ b/Fly App/lib/ui/osm_tiles.dart @@ -0,0 +1,33 @@ +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, + ); + +/// 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. +class _OsmTileProvider extends NetworkTileProvider { + _OsmTileProvider() + : super( + httpClient: RetryClient(Client(), whenError: (Object _, StackTrace __) => true), + silenceExceptions: true, + ); + + static final _OsmTileProvider instance = _OsmTileProvider(); + + @override + Future dispose() async {} +} diff --git a/Fly App/pubspec.lock b/Fly App/pubspec.lock index e237566..ef87579 100644 --- a/Fly App/pubspec.lock +++ b/Fly App/pubspec.lock @@ -233,7 +233,7 @@ packages: source: hosted version: "0.2.5" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" diff --git a/Fly App/pubspec.yaml b/Fly App/pubspec.yaml index fab581d..9f2482b 100644 --- a/Fly App/pubspec.yaml +++ b/Fly App/pubspec.yaml @@ -54,6 +54,8 @@ dependencies: # tiles need no API key (unlike google_maps_flutter). flutter_map: ^7.0.2 latlong2: ^0.9.1 + # Already pulled in by flutter_map; used directly for a tile client that retries. + http: ^1.6.0 # App cache / files dirs for MediaManager thumbnails and downloads. path_provider: ^2.1.4 diff --git a/Fly App/test/fly/hud_test.dart b/Fly App/test/fly/hud_test.dart index d10d496..159dc0c 100644 --- a/Fly App/test/fly/hud_test.dart +++ b/Fly App/test/fly/hud_test.dart @@ -213,6 +213,23 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('hudHidden leaves only the feed, full-screen', (WidgetTester tester) async { + final FlightModel m = modelFor(phantom4); + await pump(tester, m); + expect(find.text('64%'), findsOneWidget); + + m.hudHidden.value = true; + await tester.pump(); + expect(find.text('64%'), findsNothing, reason: 'the HUD is offstage'); + final Rect video = tester.getRect(find.byKey(const ValueKey('video-slot'))); + expect(video, Offset.zero & const Size(844, 390)); + + m.hudHidden.value = false; + await tester.pump(); + expect(find.text('64%'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + testWidgets('Phantom 4: RC + HD bars and the telemetry strip', (WidgetTester tester) async { await pump(tester, modelFor(phantom4)); expect(find.text('RC'), findsOneWidget); diff --git a/Fly App/test/fly/p9_secondary_test.dart b/Fly App/test/fly/p9_secondary_test.dart index e278014..5dff435 100644 --- a/Fly App/test/fly/p9_secondary_test.dart +++ b/Fly App/test/fly/p9_secondary_test.dart @@ -378,6 +378,8 @@ void main() { m.connected = true; m.aircraft.apply(_tick(lat: 0, lon: 0)); expect(loc.last, isNull); + m.aircraft.apply(_tick(sim: true)); + expect(loc.last, isNull, reason: 'a simulated position is not where the aircraft is'); loc.dispose(); }); @@ -532,6 +534,22 @@ void main() { await tester.pumpWidget(const SizedBox()); expect(dji.calls.last, 'setCameraMode(video)'); }); + + testWidgets('leaving puts back the mode the camera reported, not the switch default', (WidgetTester tester) async { + final FakeDjiService dji = FakeDjiService(); + // The HUD switch was never touched (captureMode keeps its video default) + // while the camera itself reported photo mode. + final FlightModel m = FlightModel()..connected = true; + m.camera.mode = 'SHOOT_PHOTO'; + await tester.pumpWidget(MaterialApp(theme: PV.theme(), home: AlbumPage(model: m, dji: dji))); + m.camera.mode = 'MEDIA_DOWNLOAD'; + m.mediaLoading = false; + m.bump(); + await tester.pump(); + + await tester.pumpWidget(const SizedBox()); + expect(dji.calls.last, 'setCameraMode(photo)'); + }); }); testWidgets('the checklist page ticks items', (WidgetTester tester) async {