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 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-21 21:13:24 +02:00
1 parent 2db557a3b4
commit cfab444c65
12 files changed
+110 -12

No files matched your search

+6
View File
@@ -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> mediaPlayback = ValueNotifier<MediaPlayback>(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<bool> hudHidden = ValueNotifier<bool>(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<Size?> videoSize = ValueNotifier<Size?>(null);
@@ -316,6 +321,7 @@ class FlightModel extends ChangeNotifier {
}
videoSize.dispose();
mediaPlayback.dispose();
hudHidden.dispose();
rcButton.dispose();
rcMasters.dispose();
spectrum.dispose();
+3 -1
View File
@@ -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!,
+9 -1
View File
@@ -83,21 +83,27 @@ class _AlbumPageState extends State<AlbumPage> {
/// 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<AlbumPage> {
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<AlbumPage> {
Future<void> _closePlayer() async {
await _transport(_dji.stopMediaVideo, 'Stop');
if (mounted) setState(() => _playing = null);
_m.hudHidden.value = false;
}
Widget _player(MediaItem item) {
+2 -1
View File
@@ -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<FindMyDronePage> {
? MapOptions(initialCameraFit: CameraFit.bounds(bounds: LatLngBounds.fromPoints(fit), padding: const EdgeInsets.all(48), maxZoom: 18))
: MapOptions(initialCenter: drone, initialZoom: 17),
children: <Widget>[
TileLayer(urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', userAgentPackageName: 'com.pilotvault.flyapp'),
osmTileLayer(),
if (path.length >= 2) PolylineLayer(polylines: <Polyline>[Polyline(points: path, strokeWidth: 2.5, color: const Color(0x993D7BF0))]),
if (_phone != null)
PolylineLayer(polylines: <Polyline>[
+2 -1
View File
@@ -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: <Widget>[
TileLayer(urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', userAgentPackageName: 'com.pilotvault.flyapp'),
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))),
+2 -4
View File
@@ -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<FlyMap> {
onTap: widget.interactive && widget.onTap != null ? (TapPosition _, LatLng at) => widget.onTap!(at) : null,
),
children: <Widget>[
TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.pilotvault.flyapp',
),
osmTileLayer(),
ListenableBuilder(
listenable: Listenable.merge(<Listenable>[widget.trail, FlyPrefs.showRoute]),
builder: (BuildContext context, _) {
+15 -3
View File
@@ -199,6 +199,7 @@ class _FlyScreenState extends State<FlyScreen> {
_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<FlyScreen> {
@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<FlyScreen> {
void _push(Widget page) => Navigator.of(context).push(MaterialPageRoute<void>(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<FlyScreen> {
listenable: _m,
builder: (BuildContext context, _) => VideoLayer(connected: _m.connected),
);
final Widget map = FlyMap(
final Widget mapView = FlyMap(
key: const ValueKey<String>('map'),
model: _m,
trail: _trail,
@@ -622,11 +629,15 @@ class _FlyScreenState extends State<FlyScreen> {
}
},
);
final Widget map = Offstage(offstage: bare, child: mapView);
final Widget body = Stack(fit: StackFit.expand, children: <Widget>[
// 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) ...<Widget>[
if (bare) ...<Widget>[
Positioned.fromRect(key: const ValueKey<String>('video-slot'), rect: full, child: video),
Positioned.fromRect(key: const ValueKey<String>('map-slot'), rect: pip, child: map),
] else if (!_mapBig) ...<Widget>[
Positioned.fromRect(key: const ValueKey<String>('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<FlyScreen> {
Positioned.fromRect(key: const ValueKey<String>('map-slot'), rect: full, child: map),
Positioned.fromRect(key: const ValueKey<String>('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,
+33
View File
@@ -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<void> dispose() async {}
}
+1 -1
View File
@@ -233,7 +233,7 @@ packages:
source: hosted
version: "0.2.5"
http:
dependency: transitive
dependency: "direct main"
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
+2
View File
@@ -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
+17
View File
@@ -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<String>('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);
+18
View File
@@ -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 {