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 {