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:
1 parent
2db557a3b4
commit
cfab444c65
12 files changed
+110
-12
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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!,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>[
|
||||
|
||||
@@ -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))),
|
||||
|
||||
@@ -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, _) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -233,7 +233,7 @@ packages:
|
||||
source: hosted
|
||||
version: "0.2.5"
|
||||
http:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user