From 91ca150a356032b43681ecdba12449c9b207094a Mon Sep 17 00:00:00 2001 From: tajniak81 <13187254+tajniak81@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:07:10 +0200 Subject: [PATCH] Give the Fly App GO 4-style playback, flight records, Find My Drone and live streaming Playback: the flight screen's playback button opens the album over the live view, so a video tapped there plays on the aircraft into the feed with a transport bar. Long press selects files to download or delete (delete asks first); one file opens in a viewer with the camera's preview. Leaving the album puts the camera back in its capture mode. Flight records: every flight is recorded from take-off to landing (1 Hz track, max height / distance / speed, distance flown, battery used) and kept on the phone. When it lands while signed in, it is uploaded to the PilotVault logbook through the existing /api/drones/auto and /api/flights endpoints as an automatic-path entry with the track as GeoJSON. Simulator flights are never uploaded; flights recorded while signed out retry on sign-in. Flight logs lists them with their upload state and a track map, and the profile stats come from real flights. Find My Drone: the aircraft's last fix is kept across lost links and restarts, and shown against the phone's own GPS with distance, direction and a hand-off to the maps app. Live streaming: a new LiveStreamBridge streams the main camera to an RTMP address set in Settings > General, with a LIVE pill on the flight screen that turns amber when the uplink falls behind and stops on tap. Pre-flight checklist (manual checks plus the aircraft's live status) and an in-app Academy with six short guides that work offline. None of this has run on hardware yet. Co-Authored-By: Claude Opus 5.5 --- .../com/pilotvault/flyapp/DjiSdkBridge.kt | 1 + .../com/pilotvault/flyapp/LiveStreamBridge.kt | 164 +++++ .../com/pilotvault/flyapp/MediaBridge.kt | 145 ++++- Fly App/lib/dji_service.dart | 26 +- Fly App/lib/flight_model.dart | 37 +- Fly App/lib/fly/academy_content.dart | 171 ++++++ Fly App/lib/fly/find_my_drone.dart | 173 ++++++ Fly App/lib/fly/flight_log.dart | 153 +++++ Fly App/lib/fly/flight_record.dart | 266 ++++++++ Fly App/lib/fly/flight_record_sync.dart | 182 ++++++ Fly App/lib/fly/fly_prefs.dart | 30 + Fly App/lib/fly/hud_features.dart | 4 + Fly App/lib/fly/preflight_checklist.dart | 108 ++++ Fly App/lib/fly/settings/settings_tabs.dart | 3 + Fly App/lib/fly/state/live_stream_state.dart | 101 +++ Fly App/lib/fly/telemetry_reducer.dart | 8 + Fly App/lib/main.dart | 44 +- Fly App/lib/ui/academy_page.dart | 333 ++++++++-- Fly App/lib/ui/album_page.dart | 514 ++++++++++++++-- Fly App/lib/ui/find_my_drone_page.dart | 251 ++++++++ Fly App/lib/ui/flight_logs_page.dart | 365 ++++++++++- Fly App/lib/ui/fly/fly_screen.dart | 8 +- Fly App/lib/ui/fly/overlays/live_stream.dart | 169 +++++ .../fly/panels/settings/settings_sheet.dart | 8 + Fly App/lib/ui/profile_page.dart | 32 +- Fly App/lib/ui/pv_icons.dart | 3 + Fly App/test/fly/fake_dji.dart | 31 + Fly App/test/fly/p9_secondary_test.dart | 575 ++++++++++++++++++ 28 files changed, 3747 insertions(+), 158 deletions(-) create mode 100644 Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/LiveStreamBridge.kt create mode 100644 Fly App/lib/fly/academy_content.dart create mode 100644 Fly App/lib/fly/find_my_drone.dart create mode 100644 Fly App/lib/fly/flight_log.dart create mode 100644 Fly App/lib/fly/flight_record.dart create mode 100644 Fly App/lib/fly/flight_record_sync.dart create mode 100644 Fly App/lib/fly/preflight_checklist.dart create mode 100644 Fly App/lib/fly/state/live_stream_state.dart create mode 100644 Fly App/lib/ui/find_my_drone_page.dart create mode 100644 Fly App/lib/ui/fly/overlays/live_stream.dart create mode 100644 Fly App/test/fly/p9_secondary_test.dart diff --git a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/DjiSdkBridge.kt b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/DjiSdkBridge.kt index 6691620..9e36b76 100644 --- a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/DjiSdkBridge.kt +++ b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/DjiSdkBridge.kt @@ -114,6 +114,7 @@ class DjiSdkBridge( VirtualStickBridge(ctx), AirSenseBridge(ctx), FlyZoneBridge(ctx), + LiveStreamBridge(ctx), ) init { diff --git a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/LiveStreamBridge.kt b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/LiveStreamBridge.kt new file mode 100644 index 0000000..e3333f1 --- /dev/null +++ b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/LiveStreamBridge.kt @@ -0,0 +1,164 @@ +package com.pilotvault.flyapp + +import dji.sdk.sdkmanager.DJISDKManager +import dji.sdk.sdkmanager.LiveStreamManager +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import java.util.concurrent.Executors + +/** + * RTMP live streaming of the primary video feed (plan P9, GO 4's "Live + * streaming platform"), through the SDK's app-level [LiveStreamManager]. + * + * Methods: `startLiveStream {url, audio}` (resolves once the stream is up, or + * fails with the SDK's result code), `stopLiveStream`, `getLiveStreamState`. + * Every change, and once a second while streaming, it emits + * + * ``` + * liveStream: {streaming, status: STREAMING | STOPPED | ERROR | null, url, + * videoKbps, audioKbps, fps, seconds, cacheSize, error} + * ``` + * + * `startStream` blocks on a latch inside the SDK, so starting and stopping run + * on a single worker thread. + */ +class LiveStreamBridge(private val ctx: BridgeCtx) : SubBridge { + + private val worker = Executors.newSingleThreadExecutor() + private var listening = false + private var lastStatus: Int? = null + private var lastError: String? = null + private var polling = false + + private val statusListener = LiveStreamManager.OnLiveChangeListener { status -> + lastStatus = status + ctx.mainHandler.post { + publish() + if (status == LiveStreamManager.STATUS_STREAMING) startPolling() + } + } + + private val errorListener = LiveStreamManager.OnLiveErrorStatusListener { code, message -> + lastError = message?.takeIf { it.isNotBlank() } ?: "Error $code" + ctx.mainHandler.post { publish() } + } + + private val poll = object : Runnable { + override fun run() { + if (!polling) return + publish() + if (manager()?.isStreaming == true) { + ctx.mainHandler.postDelayed(this, POLL_MS) + } else { + polling = false + } + } + } + + private fun manager(): LiveStreamManager? = try { + DJISDKManager.getInstance().liveStreamManager + } catch (_: Throwable) { + null + } + + override fun handle(call: MethodCall, result: MethodChannel.Result): Boolean { + when (call.method) { + "startLiveStream" -> start(call.argument("url") ?: "", call.argument("audio") ?: false, result) + "stopLiveStream" -> stop(result) + "getLiveStreamState" -> { + val m = manager() + ctx.mainHandler.post { result.success(if (m == null) null else state(m)) } + } + else -> return false + } + return true + } + + private fun listen(m: LiveStreamManager) { + if (listening) return + m.registerListener(statusListener) + m.addLiveErrorStatusListener(errorListener) + listening = true + } + + private fun start(url: String, audio: Boolean, result: MethodChannel.Result) { + val m = manager() ?: run { ctx.fail(result, "Live streaming is not available until the SDK is registered"); return } + if (!url.startsWith("rtmp://") && !url.startsWith("rtmps://")) { + ctx.fail(result, "The stream address must start with rtmp://") + return + } + listen(m) + lastError = null + worker.execute { + try { + if (m.isStreaming) m.stopStream() + m.setLiveUrl(url) + m.setAudioStreamingEnabled(audio) + val code = m.startStream() + m.setStartTime() + ctx.mainHandler.post { + publish() + if (code == 0) { + startPolling() + result.success(null) + } else { + result.error("DJI_ERROR", lastError ?: "The stream did not start (code $code)", code) + } + } + } catch (e: Throwable) { + ctx.mainHandler.post { result.error("DJI_ERROR", e.message ?: "The stream did not start", null) } + } + } + } + + private fun stop(result: MethodChannel.Result) { + val m = manager() ?: run { ctx.mainHandler.post { result.success(null) }; return } + worker.execute { + try { + m.stopStream() + } catch (_: Throwable) { + // Already stopped. + } + ctx.mainHandler.post { + polling = false + publish() + result.success(null) + } + } + } + + private fun startPolling() { + if (polling) return + polling = true + ctx.mainHandler.postDelayed(poll, POLL_MS) + } + + private fun publish() { + val m = manager() ?: return + ctx.emit(mapOf("type" to "liveStream") + state(m)) + } + + private fun state(m: LiveStreamManager): Map { + val streaming = try { m.isStreaming } catch (_: Throwable) { false } + return mapOf( + "streaming" to streaming, + "status" to when (lastStatus) { + LiveStreamManager.STATUS_STREAMING -> "STREAMING" + LiveStreamManager.STATUS_STOP -> "STOPPED" + null -> null + else -> "ERROR" + }, + "url" to try { m.liveUrl } catch (_: Throwable) { null }, + "videoKbps" to if (streaming) m.liveVideoBitRate else 0, + "audioKbps" to if (streaming) m.liveAudioBitRate else 0, + "fps" to if (streaming) m.liveVideoFps.toDouble() else 0.0, + "seconds" to if (streaming) m.streamTime else 0L, + "cacheSize" to if (streaming) m.liveVideoCacheSize else 0, + "error" to lastError, + ) + } + + private companion object { + const val POLL_MS = 1000L + } +} diff --git a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/MediaBridge.kt b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/MediaBridge.kt index 7a4e250..56cebd1 100644 --- a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/MediaBridge.kt +++ b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/MediaBridge.kt @@ -2,6 +2,7 @@ package com.pilotvault.flyapp import android.graphics.Bitmap import dji.common.camera.SettingsDefinitions +import dji.common.error.DJICameraError import dji.common.error.DJIError import dji.common.util.CommonCallbacks import dji.sdk.media.DownloadListener @@ -13,31 +14,77 @@ import java.io.File import java.io.FileOutputStream /** - * On-drone media via the SDK [MediaManager]: list the SD card, fetch thumbnails, - * and download originals. Emits `mediaList` (the file catalogue) and - * `mediaDownload` (per-file progress). Thumbnails/downloads are written to the - * app's cache / files dirs and their paths handed back to Dart. + * On-drone media via the SDK [MediaManager]: list the SD card, fetch thumbnails + * and previews, download originals, delete, and play videos on the aircraft + * (plan P9's in-FPV playback). Emits `mediaList` (the file catalogue), + * `mediaDownload` (per-file progress) and `mediaPlayback` (the aircraft's video + * player: `{index, status: PLAYING | PAUSED | STOPPED | UNKNOWN, position}`, s). + * Thumbnails / previews / downloads are written to the app's cache / files dirs + * and their paths handed back to Dart. + * + * Files are addressed by their index in the last refreshed list; a delete + * refreshes the list, so Dart must re-read indices from the next `mediaList`. + * Cached images are named by [cacheKey], which survives that re-indexing. */ class MediaBridge(private val ctx: BridgeCtx) : SubBridge { // Snapshot of the last-refreshed file list; Dart addresses files by index. private var files: List = emptyList() + // The manager the playback listener is attached to, so it is added once. + private var listening: MediaManager? = null + + private val playbackListener = MediaManager.VideoPlaybackStateListener { state -> + val playing = state?.playingMediaFile + val values = mapOf( + "type" to "mediaPlayback", + "index" to (playing?.let { files.indexOf(it) } ?: -1), + "status" to (state?.playbackStatus?.name ?: "UNKNOWN"), + "position" to (state?.playingPosition?.toDouble() ?: 0.0), + ) + ctx.mainHandler.post { ctx.emit(values) } + } + private fun manager(): MediaManager? = ctx.aircraft()?.camera?.mediaManager override fun handle(call: MethodCall, result: MethodChannel.Result): Boolean { when (call.method) { "refreshMediaList" -> refresh(result) "fetchThumbnail" -> fetchThumbnail(call.argument("index") ?: -1, result) + "fetchMediaPreview" -> fetchPreview(call.argument("index") ?: -1, result) "downloadMedia" -> download(call.argument("index") ?: -1, result) + "deleteMedia" -> delete(call.argument>("indices") ?: emptyList(), result) + "playMediaVideo" -> play(call.argument("index") ?: -1, result) + "pauseMediaVideo" -> withManager(result) { it.pause(ctx.completion(result)) } + "resumeMediaVideo" -> withManager(result) { it.resume(ctx.completion(result)) } + "stopMediaVideo" -> withManager(result) { it.stop(ctx.completion(result)) } + "seekMediaVideo" -> withManager(result) { + it.moveToPosition((call.argument("seconds") ?: 0.0).toFloat(), ctx.completion(result)) + } else -> return false } return true } + override fun reset() { + listening?.removeMediaUpdatedVideoPlaybackStateListener(playbackListener) + listening = null + files = emptyList() + } + + private fun withManager(result: MethodChannel.Result, block: (MediaManager) -> Unit) { + val mgr = manager() ?: run { ctx.fail(result, "Media manager not supported"); return } + block(mgr) + } + private fun refresh(result: MethodChannel.Result) { val camera = ctx.aircraft()?.camera ?: run { ctx.fail(result, "No camera connected"); return } val mgr = camera.mediaManager ?: run { ctx.fail(result, "Media manager not supported"); return } + if (listening !== mgr) { + listening?.removeMediaUpdatedVideoPlaybackStateListener(playbackListener) + mgr.addMediaUpdatedVideoPlaybackStateListener(playbackListener) + listening = mgr + } // The media manager only serves files while the camera is in download mode. camera.setMode( @@ -57,7 +104,7 @@ class MediaBridge(private val ctx: BridgeCtx) : SubBridge { return } files = mgr.sdCardFileListSnapshot ?: emptyList() - emitList() + emitList(mgr) ctx.mainHandler.post { result.success(files.size) } } }, @@ -67,20 +114,42 @@ class MediaBridge(private val ctx: BridgeCtx) : SubBridge { ) } - private fun emitList() { + private fun cacheKey(f: MediaFile): String = + "${f.fileName}_${f.timeCreated}".replace(Regex("[^A-Za-z0-9._-]"), "_") + + private fun emitList(mgr: MediaManager) { val list = files.mapIndexed { i, f -> val type = f.mediaType val isVideo = type == MediaFile.MediaType.MP4 || type == MediaFile.MediaType.MOV mapOf( "index" to i, + "key" to cacheKey(f), "fileName" to f.fileName, "type" to if (isVideo) "video" else "photo", + "mediaType" to type?.name, "durationSeconds" to f.durationInSeconds.toInt(), "sizeBytes" to f.fileSize, "createdMs" to f.timeCreated, + "resolution" to f.resolution?.name, ) } - ctx.emit(mapOf("type" to "mediaList", "files" to list)) + val supportsPlayback = try { mgr.isVideoPlaybackSupported } catch (_: Throwable) { false } + ctx.emit(mapOf("type" to "mediaList", "files" to list, "videoPlayback" to supportsPlayback)) + } + + /** Writes [bmp] to the cache as [name] and answers [result] with its path. */ + private fun saveBitmap(bmp: Bitmap?, name: String, result: MethodChannel.Result) { + if (bmp == null) { + ctx.mainHandler.post { result.success(null) } + return + } + val out = File(ctx.appContext.cacheDir, name) + try { + FileOutputStream(out).use { bmp.compress(Bitmap.CompressFormat.JPEG, 90, it) } + ctx.mainHandler.post { result.success(out.absolutePath) } + } catch (e: Exception) { + ctx.mainHandler.post { result.error("IO_ERROR", e.message, null) } + } } private fun fetchThumbnail(index: Int, result: MethodChannel.Result) { @@ -91,22 +160,60 @@ class MediaBridge(private val ctx: BridgeCtx) : SubBridge { ctx.mainHandler.post { result.error("DJI_ERROR", err.description, null) } return } - val bmp: Bitmap? = file.thumbnail - if (bmp == null) { - ctx.mainHandler.post { result.success(null) } - return - } - val out = File(ctx.appContext.cacheDir, "thumb_$index.png") - try { - FileOutputStream(out).use { bmp.compress(Bitmap.CompressFormat.PNG, 90, it) } - ctx.mainHandler.post { result.success(out.absolutePath) } - } catch (e: Exception) { - ctx.mainHandler.post { result.error("IO_ERROR", e.message, null) } - } + saveBitmap(file.thumbnail, "thumb_${cacheKey(file)}.jpg", result) } }) } + /** The screen-sized preview the camera keeps beside each photo (and a still for videos). */ + private fun fetchPreview(index: Int, result: MethodChannel.Result) { + val file = files.getOrNull(index) ?: run { ctx.fail(result, "No such media"); return } + file.fetchPreview(object : CommonCallbacks.CompletionCallback { + override fun onResult(err: DJIError?) { + if (err != null) { + ctx.mainHandler.post { result.error("DJI_ERROR", err.description, null) } + return + } + saveBitmap(file.preview, "preview_${cacheKey(file)}.jpg", result) + } + }) + } + + private fun delete(indices: List, result: MethodChannel.Result) { + val mgr = manager() ?: run { ctx.fail(result, "Media manager not supported"); return } + val targets = indices.mapNotNull { files.getOrNull(it) } + if (targets.isEmpty()) { + ctx.fail(result, "No such media") + return + } + mgr.deleteFiles( + targets, + object : CommonCallbacks.CompletionCallbackWithTwoParam, DJICameraError> { + override fun onSuccess(failed: List?, error: DJICameraError?) { + val failedCount = failed?.size ?: 0 + // The snapshot no longer matches the card: re-list before answering. + files = mgr.sdCardFileListSnapshot ?: files.filter { it !in targets || failed?.contains(it) == true } + emitList(mgr) + ctx.mainHandler.post { result.success(failedCount) } + } + + override fun onFailure(error: DJIError?) { + ctx.mainHandler.post { result.error("DJI_ERROR", error?.description ?: "Delete failed", null) } + } + }, + ) + } + + private fun play(index: Int, result: MethodChannel.Result) { + val mgr = manager() ?: run { ctx.fail(result, "Media manager not supported"); return } + val file = files.getOrNull(index) ?: run { ctx.fail(result, "No such media"); return } + if (!mgr.isVideoPlaybackSupported) { + ctx.fail(result, "This aircraft cannot play videos itself; download it instead") + return + } + mgr.playVideoMediaFile(file, ctx.completion(result)) + } + private fun download(index: Int, result: MethodChannel.Result) { val file = files.getOrNull(index) ?: run { ctx.fail(result, "No such media"); return } val destDir = ctx.appContext.getExternalFilesDir(null) ?: ctx.appContext.filesDir diff --git a/Fly App/lib/dji_service.dart b/Fly App/lib/dji_service.dart index 02c2022..b7c4e99 100644 --- a/Fly App/lib/dji_service.dart +++ b/Fly App/lib/dji_service.dart @@ -417,8 +417,30 @@ class DjiService { _methods.invokeMethod('fetchThumbnail', {'index': index}); Future downloadMedia(int index) => _methods.invokeMethod('downloadMedia', {'index': index}); - Future deleteMedia(int index) => - _methods.invokeMethod('deleteMedia', {'index': index}); + /// The camera's screen-sized preview of a photo (or a still of a video). + Future fetchMediaPreview(int index) => + _methods.invokeMethod('fetchMediaPreview', {'index': index}); + + /// Deletes the files off the card; answers how many the camera refused. A new + /// `mediaList` (re-indexed) follows. + Future deleteMedia(List indices) async => + await _methods.invokeMethod('deleteMedia', {'indices': indices}) ?? 0; + + /// Plays a video on the aircraft, into the live video feed (plan P9); + /// `mediaPlayback` events follow its position. + Future playMediaVideo(int index) => + _methods.invokeMethod('playMediaVideo', {'index': index}); + Future pauseMediaVideo() => _methods.invokeMethod('pauseMediaVideo'); + Future resumeMediaVideo() => _methods.invokeMethod('resumeMediaVideo'); + Future stopMediaVideo() => _methods.invokeMethod('stopMediaVideo'); + Future seekMediaVideo(double seconds) => + _methods.invokeMethod('seekMediaVideo', {'seconds': seconds}); + + // ── Live streaming (plan P9, `LiveStreamBridge.kt`) ───────────────────────── + /// Streams the primary video feed to an RTMP [url]; `liveStream` events follow. + Future startLiveStream(String url, {bool audio = false}) => + _methods.invokeMethod('startLiveStream', {'url': url, 'audio': audio}); + Future stopLiveStream() => _methods.invokeMethod('stopLiveStream'); // ── DJI account (optional) ────────────────────────────────────────────────── Future djiLogin() => _methods.invokeMethod('djiLogin'); diff --git a/Fly App/lib/flight_model.dart b/Fly App/lib/flight_model.dart index 49964d9..2c63dd0 100644 --- a/Fly App/lib/flight_model.dart +++ b/Fly App/lib/flight_model.dart @@ -13,6 +13,7 @@ import 'fly/state/fly_zone_state.dart'; import 'fly/state/gimbal_state.dart'; import 'fly/state/key_state.dart'; import 'fly/state/link_state.dart'; +import 'fly/state/live_stream_state.dart'; import 'fly/state/mission_state.dart'; import 'fly/state/vision_state.dart'; import 'uploader.dart'; @@ -31,6 +32,8 @@ class MediaItem { required this.index, required this.fileName, required this.isVideo, + this.key, + this.mediaType, this.durationSeconds, this.sizeBytes, this.createdMs, @@ -41,6 +44,12 @@ class MediaItem { final int index; final String fileName; final bool isVideo; + + /// Stable across re-listing (the index is not): file name + creation time. + final String? key; + + /// The SDK's `MediaFile.MediaType` name: `JPEG`, `RAW_DNG`, `MP4`, `PANORAMA` … + final String? mediaType; final int? durationSeconds; final int? sizeBytes; final int? createdMs; @@ -51,6 +60,20 @@ class MediaItem { /// On-disk path of the fully downloaded original, once available. String? localPath; + /// The camera's screen-sized preview (photos), once fetched. + String? previewPath; + + bool get isPano => mediaType == 'PANORAMA' || mediaType == 'PHOTO_FOLDER'; + + /// `12.4 MB`, or null when the camera did not say. + String? get sizeLabel { + final int? b = sizeBytes; + if (b == null || b <= 0) return null; + if (b >= 1 << 30) return '${(b / (1 << 30)).toStringAsFixed(1)} GB'; + if (b >= 1 << 20) return '${(b / (1 << 20)).toStringAsFixed(1)} MB'; + return '${(b / 1024).ceil()} kB'; + } + /// `null` for anything that shouldn't carry a duration. The SDK reports /// `durationInSeconds` for *every* file, so stills come back with junk — /// a Mavic Pro's photos yielded -36:35, -136:23 and 545:26 on the SD card. @@ -67,6 +90,8 @@ class MediaItem { index: (m['index'] as num).toInt(), fileName: (m['fileName'] as String?) ?? 'file', isVideo: (m['type'] as String?) == 'video', + key: m['key'] as String?, + mediaType: m['mediaType'] as String?, durationSeconds: (m['durationSeconds'] as num?)?.toInt(), sizeBytes: (m['sizeBytes'] as num?)?.toInt(), createdMs: (m['createdMs'] as num?)?.toInt(), @@ -142,12 +167,18 @@ class FlightModel extends ChangeNotifier { /// The calibration wizards' progress (plan P6b). final CalibrationState calibration = CalibrationState(); + /// RTMP live streaming (plan P9); app-level, survives disconnects. + final LiveStreamState liveStream = LiveStreamState(); + + /// The aircraft's video player while a video plays from the SD card (plan P9). + final ValueNotifier mediaPlayback = ValueNotifier(const MediaPlayback()); + /// 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); late final List _subs = [ - aircraft, battery, links, camera, gimbal, vision, alerts, mission, keys, calibration, airSense, flyZones, // + aircraft, battery, links, camera, gimbal, vision, alerts, mission, keys, calibration, airSense, flyZones, liveStream, // ]; /// Wipes the aircraft-bound sub-models on disconnect. @@ -252,6 +283,9 @@ class FlightModel extends ChangeNotifier { List media = []; bool mediaLoading = false; + /// The aircraft can play its videos into the live feed (`MediaManager.isVideoPlaybackSupported`). + bool mediaVideoPlayback = false; + // ── DJI account (optional, in addition to PilotVault) ─────────────────────── String djiAccountState = 'unknown'; // notLoggedIn / tokenOutOfDate / authorized / … String? djiAccountUser; @@ -271,6 +305,7 @@ class FlightModel extends ChangeNotifier { sub.dispose(); } videoSize.dispose(); + mediaPlayback.dispose(); super.dispose(); } } diff --git a/Fly App/lib/fly/academy_content.dart b/Fly App/lib/fly/academy_content.dart new file mode 100644 index 0000000..1b6221b --- /dev/null +++ b/Fly App/lib/fly/academy_content.dart @@ -0,0 +1,171 @@ +import 'package:flutter/foundation.dart'; + +/// One section of an [AcademyArticle]: a heading, then paragraphs; a paragraph +/// starting with `• ` renders as a bullet. +@immutable +class AcademySection { + const AcademySection(this.heading, this.paragraphs); + + final String heading; + final List paragraphs; +} + +/// In-app Academy reading (plan P9): short guides written for this app, so +/// they work in the field without a connection. +@immutable +class AcademyArticle { + const AcademyArticle({ + required this.id, + required this.icon, + required this.title, + required this.subtitle, + required this.sections, + }); + + final String id; + final String icon; + final String title; + final String subtitle; + final List sections; + + /// About 200 words a minute. + int get minutes { + final int words = sections.fold(0, (int n, AcademySection s) => n + s.paragraphs.fold(0, (int m, String p) => m + p.split(' ').length)); + return (words / 200).ceil().clamp(1, 60); + } +} + +const List academyArticles = [ + AcademyArticle( + id: 'firstFlight', + icon: 'takeoff', + title: 'Your first flight', + subtitle: 'From the case to a safe landing', + sections: [ + AcademySection('Before you go', [ + 'Charge the aircraft batteries, the remote controller and this phone the evening before. Check the fly zones and the weather for where you are going.', + 'Sign in to PilotVault, so the flight lands in your logbook by itself.', + ]), + AcademySection('On site', [ + '• Work through the pre-flight checklist in the Academy.', + '• Connect the aircraft from the home screen and tap GO FLY.', + '• Open the status chip at the top: every row should be green before take-off. The app also offers it before the first take-off of each connection.', + '• Wait for a strong GPS signal, so the home point is recorded where you stand.', + ]), + AcademySection('Take-off and hover', [ + 'Take off from the left rail and slide to confirm. The aircraft climbs to about 1.2 m and hovers. Stay there for a moment: if it drifts, land and check the compass and IMU.', + 'Fly gently at first. Keep the aircraft in sight, in front of you, and away from people, roads and trees.', + ]), + AcademySection('Landing', [ + 'Bring the aircraft back over a clear spot, descend slowly, and hold the left stick down after touchdown until the motors stop, or use Land on the rail.', + 'Back on the ground, the flight is saved under Flight logs.', + ]), + ], + ), + AcademyArticle( + id: 'rth', + icon: 'rth', + title: 'Return to home', + subtitle: 'What the aircraft does when you let go', + sections: [ + AcademySection('The home point', [ + 'The aircraft records its home point at take-off once it has a good GPS fix. The H on the map shows it. You can move it to your own position in Settings → Safety, for example when you walk with the aircraft.', + ]), + AcademySection('When it flies home', [ + '• You tap RTH on the rail and slide to confirm.', + '• The battery is only just enough to get back (smart RTH counts down and lets you cancel).', + '• The signal from the remote controller is lost (failsafe).', + ]), + AcademySection('Return-to-home height', [ + 'Further than about 20 m away, the aircraft first climbs to the return-to-home height, then flies straight home. Set that height above the tallest obstacle on the way, in Settings → Safety.', + 'Aircraft without obstacle sensing fly straight into anything below that height. Aircraft with sensing still cannot see thin branches or wires.', + ]), + AcademySection('Taking back control', [ + 'Cancel from the rail, or with the remote\'s pause button. On most aircraft, moving the sticks during the descent only adjusts the landing.', + ]), + ], + ), + AcademyArticle( + id: 'modes', + icon: 'crosshair', + title: 'Intelligent flight modes', + subtitle: 'Waypoints, ActiveTrack, TapFly, on-screen sticks', + sections: [ + AcademySection('Before any mode', [ + 'Every mode starts through the same safety checks as take-off: a connected aircraft, enough battery, a GPS fix, and no other mode running. If one fails, the app says why instead of starting.', + 'A mode flies the aircraft for you; it does not watch out for you. Keep it in sight and your thumbs on the sticks.', + ]), + AcademySection('Waypoints', [ + 'Tap MODES → Waypoints. The map opens big: tap to drop points, then set height, speed, heading and camera actions per point. Saved routes sync to PilotVault when you are signed in.', + ]), + AcademySection('ActiveTrack and TapFly', [ + 'ActiveTrack follows a subject you draw a box around on the video. TapFly flies towards the point you tap. Both stop with the Stop button, the remote\'s pause button, or a firm stick input.', + ]), + AcademySection('On-screen sticks', [ + 'For aircraft flown without a remote, or for practice in the simulator. They are off in real flight on aircraft with a remote unless you enable them under Developer settings.', + ]), + ], + ), + AcademyArticle( + id: 'batteries', + icon: 'battery', + title: 'Batteries and weather', + subtitle: 'Cold, wind and how long you really have', + sections: [ + AcademySection('Battery care', [ + '• Plan to land with 25–30 % left; the bar at the top shows where RTH and landing will start.', + '• Below about 15 °C, keep batteries warm before flight and hover for a minute after take-off.', + '• Store batteries at about half charge, and never charge one that is swollen or damaged.', + ]), + AcademySection('Wind', [ + 'Wind is stronger at height than on the ground. If the aircraft warns about strong wind, come lower and closer: flying home against the wind takes much more battery than flying out with it.', + ]), + AcademySection('Rain, fog and cold', [ + 'Most consumer aircraft are not water resistant. Fog and low cloud also hide the aircraft from you and from other airspace users.', + ]), + ], + ), + AcademyArticle( + id: 'rules', + icon: 'shield', + title: 'Flying legally in the EU', + subtitle: 'Open category basics and your logbook', + sections: [ + AcademySection('The open category, in short', [ + '• Stay below 120 m above the ground and keep the aircraft in visual line of sight.', + '• Register as an operator with your national aviation authority and put your operator ID on the aircraft.', + '• Pass the online A1/A3 training for aircraft from 250 g and those with a C1 class label; A2 adds rules for flying closer to people.', + '• Never fly over gatherings of people, and respect airports and restricted zones.', + ]), + AcademySection('Fly zones', [ + 'The map shows DJI\'s GEO zones and the aircraft enforces them. National zones can differ: also check your authority\'s official drone map before a flight.', + ]), + AcademySection('Your logbook', [ + 'Some countries require a flight log (in Denmark, BEK 1649 § 5). PilotVault records each flight from this app and fills in the date, times, aircraft, area and maximum height for you. Check each entry in the PilotVault logbook.', + ]), + AcademySection('Rules change', [ + 'This is a summary, not legal advice. Your national authority\'s website has the current rules.', + ]), + ], + ), + AcademyArticle( + id: 'emergencies', + icon: 'alert', + title: 'When things go wrong', + subtitle: 'Lost signal, compass errors, a missing aircraft', + sections: [ + AcademySection('Lost video or signal', [ + 'Point the remote\'s antennas towards the aircraft and do not move the sticks hastily. If the signal is lost completely, the aircraft returns home on its own (unless set to hover or land): walk to open ground near the home point.', + ]), + AcademySection('Compass or IMU error', [ + 'In flight, the aircraft may drop to ATTI mode and drift with the wind. Fly it back manually, slowly, and land. On the ground, calibrate from Settings → Safety → Calibration & maintenance, away from metal.', + ]), + AcademySection('Low battery far away', [ + 'Start RTH early; if it cannot make it, land in the safest reachable spot and fetch it.', + ]), + AcademySection('Find my drone', [ + 'The app keeps the aircraft\'s last known position, even after the link is lost or the app restarts. Open Flight logs → Find my drone for the distance and direction from where you stand.', + ]), + ], + ), +]; diff --git a/Fly App/lib/fly/find_my_drone.dart b/Fly App/lib/fly/find_my_drone.dart new file mode 100644 index 0000000..7ed5198 --- /dev/null +++ b/Fly App/lib/fly/find_my_drone.dart @@ -0,0 +1,173 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../flight_model.dart'; +import 'flight_record.dart' show validFix; +import 'hud_geometry.dart'; +import 'state/aircraft_state.dart'; + +/// Where the aircraft was last seen (plan P9 "Find My Drone"). +@immutable +class LastKnownPosition { + const LastKnownPosition({ + required this.latitude, + required this.longitude, + required this.at, + this.altitude, + this.model, + this.inFlight = false, + this.batteryPercent, + }); + + static LastKnownPosition? fromJson(Map j) { + final Object? lat = j['lat'], lon = j['lon'], at = j['at']; + if (lat is! num || lon is! num || at is! num) return null; + return LastKnownPosition( + latitude: lat.toDouble(), + longitude: lon.toDouble(), + at: DateTime.fromMillisecondsSinceEpoch(at.toInt()), + altitude: (j['alt'] as num?)?.toDouble(), + model: j['model'] as String?, + inFlight: j['inFlight'] == true, + batteryPercent: (j['battery'] as num?)?.toInt(), + ); + } + + final double latitude; + final double longitude; + final DateTime at; + final double? altitude; // m above take-off + final String? model; + + /// It was airborne when the link went: it may have drifted, landed or crashed nearby. + final bool inFlight; + final int? batteryPercent; + + Map toJson() => { + 'lat': latitude, + 'lon': longitude, + 'at': at.millisecondsSinceEpoch, + 'alt': altitude, + 'model': model, + 'inFlight': inFlight, + 'battery': batteryPercent, + }; + + /// Metres from ([lat], [lon]). + double distanceFrom(double lat, double lon) => HudGeometry.distance(lat, lon, latitude, longitude); + + /// Degrees clockwise from north, from ([lat], [lon]) towards the aircraft. + double bearingFrom(double lat, double lon) => HudGeometry.bearing(lat, lon, latitude, longitude); +} + +/// `N`, `NE`, … for a bearing in degrees. +String compassPoint(double bearing) { + const List points = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']; + return points[(((bearing % 360) + 360) % 360 / 45).round() % 8]; +} + +/// `850 m`, `2.4 km`. +String distanceLabel(double m) => m >= 1000 ? '${(m / 1000).toStringAsFixed(1)} km' : '${m.round()} m'; + +/// `just now`, `12 min ago`, `3 h ago`, `2 days ago`. +String agoLabel(DateTime at, DateTime now) { + final Duration d = now.difference(at); + if (d.inMinutes < 1) return 'just now'; + if (d.inHours < 1) return '${d.inMinutes} min ago'; + if (d.inDays < 1) return '${d.inHours} h ago'; + return d.inDays == 1 ? '1 day ago' : '${d.inDays} days ago'; +} + +/// Keeps the aircraft's last known position on the phone, so it can be found +/// after the link is lost or the app restarts. While connected it saves the +/// position every [flyingEvery] in flight (or [groundEvery] on the ground) +/// and at once when the link goes. +class AircraftLocator extends ChangeNotifier { + AircraftLocator({ + required this.model, + DateTime Function()? clock, + this.flyingEvery = const Duration(seconds: 3), + this.groundEvery = const Duration(seconds: 30), + Future Function()? prefs, + }) : _clock = clock ?? DateTime.now, + _prefs = prefs ?? SharedPreferences.getInstance { + model.aircraft.addListener(_onAircraft); + model.addListener(_onModel); + } + + static const String _key = 'fly.lastKnownPosition'; + + final FlightModel model; + final DateTime Function() _clock; + final Duration flyingEvery; + final Duration groundEvery; + final Future Function() _prefs; + + LastKnownPosition? _last; + DateTime? _savedAt; + bool _dirty = false; + bool _wasConnected = false; + + LastKnownPosition? get last => _last; + + Future load() async { + try { + final String? raw = (await _prefs()).getString(_key); + final Object? j = raw == null ? null : jsonDecode(raw); + if (j is Map && _last == null) { + _last = LastKnownPosition.fromJson(Map.from(j)); + notifyListeners(); + } + } catch (_) { + // Nothing saved yet, or unreadable: start empty. + } + } + + void _onAircraft() { + final AircraftState a = model.aircraft; + if (!model.connected || !validFix(a.latitude, a.longitude)) return; + final DateTime now = _clock(); + _last = LastKnownPosition( + latitude: a.latitude!, + longitude: a.longitude!, + at: now, + altitude: a.altitude, + model: model.model, + inFlight: a.isFlying == true, + batteryPercent: model.battery.percent, + ); + _dirty = true; + final Duration every = a.isFlying == true ? flyingEvery : groundEvery; + if (_savedAt == null || now.difference(_savedAt!) >= every) _save(now); + } + + void _onModel() { + if (model.connected == _wasConnected) return; + _wasConnected = model.connected; + // The link just went: what we have is the last word. + if (!model.connected && _dirty) _save(_clock()); + notifyListeners(); + } + + Future _save(DateTime now) async { + _savedAt = now; + _dirty = false; + notifyListeners(); + try { + final SharedPreferences prefs = await _prefs(); + // Overlapping saves can resume out of order: each writes the newest + // position, never the one it started with. + final LastKnownPosition? p = _last; + if (p != null) await prefs.setString(_key, jsonEncode(p.toJson())); + } catch (_) {} + } + + @override + void dispose() { + model.aircraft.removeListener(_onAircraft); + model.removeListener(_onModel); + super.dispose(); + } +} diff --git a/Fly App/lib/fly/flight_log.dart b/Fly App/lib/fly/flight_log.dart new file mode 100644 index 0000000..c14cf54 --- /dev/null +++ b/Fly App/lib/fly/flight_log.dart @@ -0,0 +1,153 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; + +import 'flight_record.dart'; +import 'flight_record_sync.dart'; +import 'fly_prefs.dart'; + +/// Where recorded flights live on the phone. +abstract class FlightRecordStore { + Future> loadAll(); + Future save(FlightRecord r); + Future delete(String id); +} + +/// One JSON file per flight under the app's documents directory +/// (`flight_records/.json`); an unreadable file is skipped, not fatal. +class FileFlightRecordStore implements FlightRecordStore { + FileFlightRecordStore({Future Function()? root}) : _root = root ?? getApplicationDocumentsDirectory; + + final Future Function() _root; + Directory? _dir; + + Future _folder() async { + if (_dir != null) return _dir!; + final Directory d = Directory('${(await _root()).path}${Platform.pathSeparator}flight_records'); + if (!await d.exists()) await d.create(recursive: true); + return _dir = d; + } + + File _file(Directory d, String id) => File('${d.path}${Platform.pathSeparator}$id.json'); + + @override + Future> loadAll() async { + final Directory d = await _folder(); + final List out = []; + await for (final FileSystemEntity f in d.list()) { + if (f is! File || !f.path.endsWith('.json')) continue; + try { + final Object? j = jsonDecode(await f.readAsString()); + if (j is Map) out.add(FlightRecord.fromJson(Map.from(j))); + } catch (_) { + // A half-written file from a crash: skip it. + } + } + return out; + } + + @override + Future save(FlightRecord r) async { + final File f = _file(await _folder(), r.id); + // Write then rename, so a crash never leaves a truncated record. + final File tmp = File('${f.path}.tmp'); + await tmp.writeAsString(jsonEncode(r.toJson()), flush: true); + await tmp.rename(f.path); + } + + @override + Future delete(String id) async { + final File f = _file(await _folder(), id); + if (await f.exists()) await f.delete(); + } +} + +/// The phone's flight records (plan P9): every flight the [FlightRecorder] +/// finishes is saved here and, while signed in and +/// [FlyPrefs.autoUploadFlights] is on, uploaded to the API Server's logbook. +/// Flights that could not be uploaded stay pending and retry on [uploadPending]. +class FlightLog extends ChangeNotifier { + FlightLog({required this.store, required this.remote}); + + final FlightRecordStore store; + final LogbookRemote remote; + + List _records = []; + bool _loaded = false; + final Set _uploading = {}; + + /// Newest first. + List get records => List.unmodifiable(_records); + bool get loaded => _loaded; + bool isUploading(FlightRecord r) => _uploading.contains(r.id); + + /// Real flights not in the logbook yet. + List get pending => [for (final FlightRecord r in _records) if (!r.uploaded && !r.simulated) r]; + + /// The latest flight, which is where Find My Drone looks first. + FlightRecord? get latest => _records.isEmpty ? null : _records.first; + + Future load() async { + try { + _records = await store.loadAll(); + } catch (_) { + _records = []; + } + _sort(); + _loaded = true; + notifyListeners(); + } + + void _sort() => _records.sort((FlightRecord a, FlightRecord b) => b.start.compareTo(a.start)); + + /// A flight the recorder just finished. + Future add(FlightRecord r) async { + _records.removeWhere((FlightRecord x) => x.id == r.id); + _records.add(r); + _sort(); + notifyListeners(); + await _save(r); + if (FlyPrefs.autoUploadFlights.value && remote.available && !r.simulated) await upload(r); + } + + Future _save(FlightRecord r) async { + try { + await store.save(r); + } catch (_) { + // Storage full or gone: the record lives in memory until the app closes. + } + } + + Future upload(FlightRecord r) async { + if (r.uploaded || !_uploading.add(r.id)) return r; + notifyListeners(); + try { + await uploadFlightRecord(r, remote); + await _save(r); + } finally { + _uploading.remove(r.id); + notifyListeners(); + } + return r; + } + + /// Retries every pending flight; answers how many made it. + Future uploadPending() async { + if (!remote.available) return 0; + int ok = 0; + for (final FlightRecord r in pending) { + if ((await upload(r)).uploaded) ok++; + } + return ok; + } + + Future delete(FlightRecord r) async { + _records.removeWhere((FlightRecord x) => x.id == r.id); + notifyListeners(); + try { + await store.delete(r.id); + } catch (_) {} + } +} diff --git a/Fly App/lib/fly/flight_record.dart b/Fly App/lib/fly/flight_record.dart new file mode 100644 index 0000000..a9c7d2e --- /dev/null +++ b/Fly App/lib/fly/flight_record.dart @@ -0,0 +1,266 @@ +import 'package:flutter/foundation.dart'; +import 'package:latlong2/latlong.dart' show LatLng; + +import '../flight_model.dart'; +import 'hud_geometry.dart'; +import 'state/aircraft_state.dart'; +import 'state/parse.dart'; + +/// One sample of a recorded flight: where the aircraft was [t] seconds after take-off. +@immutable +class TrackPoint { + const TrackPoint(this.latitude, this.longitude, this.altitude, this.t); + + /// Stored compactly as `[lat, lon, alt, t]`. + factory TrackPoint.fromJson(List j) => + TrackPoint(finite(j[0]) ?? 0, finite(j[1]) ?? 0, finite(j[2]) ?? 0, asInt(j[3]) ?? 0); + + final double latitude; + final double longitude; + final double altitude; // m, relative to take-off + final int t; // s since take-off + + LatLng get latLng => LatLng(latitude, longitude); + + List toJson() => [ + double.parse(latitude.toStringAsFixed(7)), + double.parse(longitude.toStringAsFixed(7)), + double.parse(altitude.toStringAsFixed(1)), + t, + ]; +} + +/// One flight, take-off to landing, as the Fly App recorded it from telemetry +/// (plan P9 "flight records"). Kept on the phone by `FlightRecordStore` and +/// uploaded to the API Server's logbook by `FlightLogSync`. +class FlightRecord { + FlightRecord({ + required this.id, + required this.start, + required this.end, + this.aircraftModel, + this.flightControllerSerial, + this.firmware, + this.homeLatitude, + this.homeLongitude, + List? track, + this.maxAltitude = 0, + this.maxDistance = 0, + this.maxSpeed = 0, + this.distanceFlown = 0, + this.batteryStart, + this.batteryEnd, + this.simulated = false, + this.logbookId, + this.uploadError, + }) : track = track ?? []; + + factory FlightRecord.fromJson(Map j) => FlightRecord( + id: asString(j['id']) ?? '', + start: DateTime.fromMillisecondsSinceEpoch(asInt(j['start']) ?? 0), + end: DateTime.fromMillisecondsSinceEpoch(asInt(j['end']) ?? 0), + aircraftModel: asString(j['model']), + flightControllerSerial: asString(j['fcSerial']), + firmware: asString(j['firmware']), + homeLatitude: finite(j['homeLat']), + homeLongitude: finite(j['homeLon']), + track: [ + for (final Object? p in (j['track'] as List? ?? const [])) + if (p is List && p.length >= 4) TrackPoint.fromJson(p), + ], + maxAltitude: finite(j['maxAlt']) ?? 0, + maxDistance: finite(j['maxDist']) ?? 0, + maxSpeed: finite(j['maxSpeed']) ?? 0, + distanceFlown: finite(j['distance']) ?? 0, + batteryStart: asInt(j['batteryStart']), + batteryEnd: asInt(j['batteryEnd']), + simulated: asBool(j['simulated']) ?? false, + logbookId: asString(j['logbookId']), + uploadError: asString(j['uploadError']), + ); + + /// `fr-`, also the file name. + final String id; + final DateTime start; + DateTime end; + final String? aircraftModel; + final String? flightControllerSerial; + final String? firmware; + double? homeLatitude; + double? homeLongitude; + final List track; + + double maxAltitude; // m above take-off + double maxDistance; // m from home + double maxSpeed; // m/s horizontal + double distanceFlown; // m along the track + int? batteryStart; // % + int? batteryEnd; // % + + /// Flown in the flight controller's simulator: never a logbook flight. + bool simulated; + + /// The API Server's flight id once uploaded. + String? logbookId; + + /// Why the last upload failed, until one succeeds. + String? uploadError; + + bool get uploaded => logbookId != null; + Duration get duration => end.difference(start); + + /// The last place the aircraft was seen on this flight. + TrackPoint? get lastPoint => track.isEmpty ? null : track.last; + + Map toJson() => { + 'id': id, + 'start': start.millisecondsSinceEpoch, + 'end': end.millisecondsSinceEpoch, + 'model': aircraftModel, + 'fcSerial': flightControllerSerial, + 'firmware': firmware, + 'homeLat': homeLatitude, + 'homeLon': homeLongitude, + 'track': [for (final TrackPoint p in track) p.toJson()], + 'maxAlt': maxAltitude, + 'maxDist': maxDistance, + 'maxSpeed': maxSpeed, + 'distance': distanceFlown, + 'batteryStart': batteryStart, + 'batteryEnd': batteryEnd, + 'simulated': simulated, + 'logbookId': logbookId, + 'uploadError': uploadError, + }; + + /// `12 min 05 s`, `45 s`. + String get durationLabel { + final int s = duration.inSeconds; + if (s < 60) return '$s s'; + final int h = s ~/ 3600, m = (s % 3600) ~/ 60; + final String ss = (s % 60).toString().padLeft(2, '0'); + return h > 0 ? '$h h ${m.toString().padLeft(2, '0')} min' : '$m min $ss s'; + } +} + +/// A usable GPS fix: present and not the SDK's (0, 0) "no fix". +bool validFix(double? lat, double? lon) => lat != null && lon != null && !(lat == 0 && lon == 0) && lat.abs() <= 90 && lon.abs() <= 180; + +/// Records every flight from telemetry (plan P9): a flight starts when the +/// flight controller reports `isFlying`, and ends once it has reported landed +/// for [landedGrace], or when the aircraft disconnects mid-air. Each finished +/// flight goes to [onFinished]; flights shorter than [minDuration] (a hop, a +/// glitch) are dropped. +/// +/// The track keeps a point every [sampleEvery] with a valid fix; maxima are +/// taken on every tick. +class FlightRecorder extends ChangeNotifier { + FlightRecorder({ + required this.model, + required this.onFinished, + DateTime Function()? clock, + this.sampleEvery = const Duration(seconds: 1), + this.landedGrace = const Duration(seconds: 5), + this.minDuration = const Duration(seconds: 5), + }) : _clock = clock ?? DateTime.now { + model.aircraft.addListener(_onAircraft); + model.addListener(_onModel); + } + + final FlightModel model; + final void Function(FlightRecord record) onFinished; + final DateTime Function() _clock; + final Duration sampleEvery; + final Duration landedGrace; + final Duration minDuration; + + FlightRecord? _current; + DateTime? _lastSample; + DateTime? _landedAt; + DateTime? _lastSeen; + + /// The flight being recorded, or null on the ground. + FlightRecord? get current => _current; + bool get recording => _current != null; + + void _onModel() { + // Gone mid-air: the flight ends where it was last seen. + if (!model.connected && _current != null) _finish(_lastSeen ?? _clock()); + } + + void _onAircraft() { + final DateTime now = _clock(); + final bool? flying = model.aircraft.isFlying; + final FlightRecord? r = _current; + if (r == null) { + if (flying == true && model.connected) _begin(now); + return; + } + if (flying == null) return; // a cleared snapshot; the disconnect ends it + _lastSeen = now; + _sample(r, now); + if (flying) { + _landedAt = null; + } else { + _landedAt ??= now; + if (now.difference(_landedAt!) >= landedGrace) _finish(_landedAt!); + } + } + + void _begin(DateTime now) { + final FlightRecord r = FlightRecord( + id: 'fr-${now.millisecondsSinceEpoch}', + start: now, + end: now, + aircraftModel: model.model, + flightControllerSerial: model.flightControllerSerial, + firmware: model.firmwareVersion, + batteryStart: model.battery.percent, + simulated: model.aircraft.simulatorActive == true, + ); + _current = r; + _lastSample = null; + _landedAt = null; + _lastSeen = now; + _sample(r, now); + notifyListeners(); + } + + void _sample(FlightRecord r, DateTime now) { + final AircraftState a = model.aircraft; + r.end = now; + if (a.simulatorActive == true) r.simulated = true; + if (validFix(a.homeLatitude, a.homeLongitude)) { + r.homeLatitude = a.homeLatitude; + r.homeLongitude = a.homeLongitude; + } + final double alt = a.altitude ?? 0; + if (alt > r.maxAltitude) r.maxAltitude = alt; + if ((a.homeDistance ?? 0) > r.maxDistance) r.maxDistance = a.homeDistance!; + if ((a.horizontalSpeed ?? 0) > r.maxSpeed) r.maxSpeed = a.horizontalSpeed!; + if (model.battery.percent != null) r.batteryEnd = model.battery.percent; + + if (!validFix(a.latitude, a.longitude)) return; + if (_lastSample != null && now.difference(_lastSample!) < sampleEvery) return; + _lastSample = now; + final TrackPoint p = TrackPoint(a.latitude!, a.longitude!, alt, now.difference(r.start).inSeconds); + final TrackPoint? last = r.lastPoint; + if (last != null) r.distanceFlown += HudGeometry.distance(last.latitude, last.longitude, p.latitude, p.longitude); + r.track.add(p); + } + + void _finish(DateTime end) { + final FlightRecord r = _current!; + _current = null; + r.end = end.isBefore(r.start) ? r.start : end; + notifyListeners(); + if (r.duration >= minDuration) onFinished(r); + } + + @override + void dispose() { + model.aircraft.removeListener(_onAircraft); + model.removeListener(_onModel); + super.dispose(); + } +} diff --git a/Fly App/lib/fly/flight_record_sync.dart b/Fly App/lib/fly/flight_record_sync.dart new file mode 100644 index 0000000..daf407a --- /dev/null +++ b/Fly App/lib/fly/flight_record_sync.dart @@ -0,0 +1,182 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import '../pb_auth.dart'; +import 'flight_record.dart'; + +/// The API Server's logbook (`/api/drones/auto`, `/api/flights`) as the +/// recorded-flight upload needs it. [ApiLogbookRemote] is the real one. +abstract class LogbookRemote { + /// Signed in, so uploads can run. + bool get available; + + /// The logbook's drone for this aircraft, created on first sight (keyed by + /// the flight controller's serial, like the Web App's fleet auto-fill). + Future ensureDrone({required String flightControllerSerial, String? model, String? firmware}); + + /// Creates the logbook entry; answers its id. + Future createFlight(Map entry); +} + +class LogbookException implements Exception { + const LogbookException(this.message); + final String message; + + @override + String toString() => message; +} + +/// `2026-10-20`, in local time like the Web App's date field. +String logbookDate(DateTime t) => + '${t.year.toString().padLeft(4, '0')}-${t.month.toString().padLeft(2, '0')}-${t.day.toString().padLeft(2, '0')}'; + +/// `14:05`, the logbook's `HH:MM`. +String logbookTime(DateTime t) => '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}'; + +/// The track as a GeoJSON LineString, thinned evenly to at most [maxPoints] +/// (the `route_geojson` field caps at 200 kB). Null with fewer than two points. +Map? routeGeoJson(List track, {int maxPoints = 500}) { + if (track.length < 2) return null; + final List pts; + if (track.length <= maxPoints) { + pts = track; + } else { + final double step = (track.length - 1) / (maxPoints - 1); + pts = [for (int i = 0; i < maxPoints; i++) track[(i * step).round()]]; + } + return { + 'type': 'LineString', + // GeoJSON is lon, lat (, alt). + 'coordinates': >[ + for (final TrackPoint p in pts) + [ + double.parse(p.longitude.toStringAsFixed(6)), + double.parse(p.latitude.toStringAsFixed(6)), + double.parse(p.altitude.toStringAsFixed(1)), + ], + ], + }; +} + +/// Where the flight took place, for the logbook's "area or route flown": +/// the take-off point, as coordinates. +String areaLabel(FlightRecord r) { + double? lat = r.homeLatitude, lon = r.homeLongitude; + if (!validFix(lat, lon) && r.track.isNotEmpty) { + lat = r.track.first.latitude; + lon = r.track.first.longitude; + } + if (!validFix(lat, lon)) return 'Position not recorded'; + String deg(double v, String pos, String neg) => '${v.abs().toStringAsFixed(5)}° ${v >= 0 ? pos : neg}'; + return 'Take-off ${deg(lat!, 'N', 'S')}, ${deg(lon!, 'E', 'W')}'; +} + +/// The `/api/flights` body for [r] flown with logbook drone [droneId]: the +/// app-recorded ("automatic") path, the §5 times and maximum height, the track +/// as GeoJSON, and the flight's numbers in the notes. +Map logbookEntry(FlightRecord r, {required String droneId}) { + final String notes = [ + 'Recorded by PilotVault Fly', + r.durationLabel, + '${(r.distanceFlown / 1000).toStringAsFixed(2)} km flown', + 'max ${r.maxDistance.round()} m from home', + 'max ${r.maxSpeed.toStringAsFixed(1)} m/s', + if (r.batteryStart != null && r.batteryEnd != null) 'battery ${r.batteryStart}% → ${r.batteryEnd}%', + ].join(' · '); + final Map? route = routeGeoJson(r.track); + return { + 'operationDate': logbookDate(r.start), + 'startTime': logbookTime(r.start), + 'endTime': logbookTime(r.end), + 'drone': droneId, + 'areaRoute': areaLabel(r), + if (route != null) 'routeGeojson': route, + // Height above take-off: the closest the aircraft's barometer gets to AGL. + 'maxAltitudeAgl': double.parse(r.maxAltitude.toStringAsFixed(1)), + 'loggingPath': 'automatic', + 'notes': notes, + }; +} + +/// Uploads one recorded flight to the logbook. Returns the record with +/// [FlightRecord.logbookId] set, or [FlightRecord.uploadError] on failure. +/// Simulator flights and flights without a serial are refused up front. +Future uploadFlightRecord(FlightRecord r, LogbookRemote remote) async { + if (r.uploaded) return r; + try { + if (r.simulated) throw const LogbookException('Simulator flights are not logbook flights'); + if (!remote.available) throw const LogbookException('Sign in to upload flights'); + final String serial = (r.flightControllerSerial ?? '').trim(); + if (serial.isEmpty) throw const LogbookException('The aircraft\'s serial was not read during this flight'); + final String drone = await remote.ensureDrone(flightControllerSerial: serial, model: r.aircraftModel, firmware: r.firmware); + r.logbookId = await remote.createFlight(logbookEntry(r, droneId: drone)); + r.uploadError = null; + } on LogbookException catch (e) { + r.uploadError = e.message; + } catch (e) { + r.uploadError = e is SocketException || e is TimeoutException ? 'The API Server could not be reached' : e.toString(); + } + return r; +} + +/// [LogbookRemote] over the API Server, as the signed-in pilot ([auth]). +class ApiLogbookRemote implements LogbookRemote { + ApiLogbookRemote({PbAuth? auth}) : _auth = auth; + + final PbAuth? _auth; + PbAuth get _a => _auth ?? auth; + + static const Duration _timeout = Duration(seconds: 15); + + @override + bool get available => _a.isAuthed; + + @override + Future ensureDrone({required String flightControllerSerial, String? model, String? firmware}) async { + final (int status, Map body) = await _send('POST', '/api/drones/auto', { + 'flightControllerSerial': flightControllerSerial, + if (model != null) 'model': model, + if (firmware != null) 'firmware': firmware, + }); + final Object? drone = body['drone']; + final Object? id = drone is Map ? drone['id'] : null; + if ((status == 200 || status == 201) && id is String && id.isNotEmpty) return id; + throw LogbookException(_error(body, 'Could not add the aircraft to the fleet ($status)')); + } + + @override + Future createFlight(Map entry) async { + final (int status, Map body) = await _send('POST', '/api/flights', entry); + final Object? flight = body['flight']; + final Object? id = flight is Map ? flight['id'] : null; + if ((status == 200 || status == 201) && id is String && id.isNotEmpty) return id; + throw LogbookException(_error(body, 'The logbook refused the flight ($status)')); + } + + static String _error(Map body, String fallback) { + final Object? e = body['error'] ?? body['message']; + return e is String && e.isNotEmpty ? e : fallback; + } + + Future<(int, Map)> _send(String method, String path, Object body) async { + final HttpClient http = HttpClient()..connectionTimeout = _timeout; + try { + final HttpClientRequest req = await http.openUrl(method, Uri.parse('${_a.serverUrl}$path')); + req.headers.set(HttpHeaders.authorizationHeader, _a.token); + req.headers.contentType = ContentType.json; + req.add(utf8.encode(jsonEncode(body))); + final HttpClientResponse resp = await req.close().timeout(_timeout); + final String text = await resp.transform(utf8.decoder).join().timeout(_timeout); + Map json = {}; + try { + final Object? decoded = text.isEmpty ? null : jsonDecode(text); + if (decoded is Map) json = Map.from(decoded); + } catch (_) {} + if (resp.statusCode == 401) throw const LogbookException('Your session expired: sign in again'); + return (resp.statusCode, json); + } finally { + http.close(force: true); + } + } +} diff --git a/Fly App/lib/fly/fly_prefs.dart b/Fly App/lib/fly/fly_prefs.dart index 2adcc56..16bad66 100644 --- a/Fly App/lib/fly/fly_prefs.dart +++ b/Fly App/lib/fly/fly_prefs.dart @@ -36,6 +36,9 @@ abstract final class FlyPrefs { static const String _radarKey = 'fly.radar'; static const String _stickModeKey = 'fly.stickMode'; static const String _sticksRealKey = 'fly.sticksRealFlight'; + static const String _autoUploadKey = 'fly.autoUploadFlights'; + static const String _liveUrlKey = 'fly.liveStreamUrl'; + static const String _liveAudioKey = 'fly.liveStreamAudio'; /// Open the pre-P2 `FlightControlPage` instead of the HUD v2 — kept /// reachable until P3's safety flows pass their simulator run (plan P2/P3). @@ -65,6 +68,15 @@ abstract final class FlyPrefs { /// that have a remote. Off by default (plan P8: simulator first). static final ValueNotifier sticksInRealFlight = ValueNotifier(false); + /// Upload each recorded flight to the API Server's logbook when it lands (plan P9). + static final ValueNotifier autoUploadFlights = ValueNotifier(true); + + /// The RTMP address live streaming sends to (plan P9), `rtmp://host/app/key`. + static final ValueNotifier liveStreamUrl = ValueNotifier(''); + + /// Send the phone's microphone with the live stream. + static final ValueNotifier liveStreamAudio = ValueNotifier(false); + static SharedPreferences? _prefs; static Future load() async { @@ -80,6 +92,9 @@ abstract final class FlyPrefs { showRadar.value = _prefs!.getBool(_radarKey) ?? true; stickMode.value = _byName(StickMode.values, _prefs!.getString(_stickModeKey)) ?? StickMode.mode2; sticksInRealFlight.value = _prefs!.getBool(_sticksRealKey) ?? false; + autoUploadFlights.value = _prefs!.getBool(_autoUploadKey) ?? true; + liveStreamUrl.value = _prefs!.getString(_liveUrlKey) ?? ''; + liveStreamAudio.value = _prefs!.getBool(_liveAudioKey) ?? false; } catch (_) { // Storage unavailable: run on the defaults. } @@ -136,6 +151,21 @@ abstract final class FlyPrefs { await _prefs?.setBool(_sticksRealKey, on); } + static Future setAutoUploadFlights(bool on) async { + autoUploadFlights.value = on; + await _prefs?.setBool(_autoUploadKey, on); + } + + static Future setLiveStreamUrl(String url) async { + liveStreamUrl.value = url.trim(); + await _prefs?.setString(_liveUrlKey, url.trim()); + } + + static Future setLiveStreamAudio(bool on) async { + liveStreamAudio.value = on; + await _prefs?.setBool(_liveAudioKey, on); + } + static T? _byName(List values, String? name) { for (final T v in values) { if (v.name == name) return v; diff --git a/Fly App/lib/fly/hud_features.dart b/Fly App/lib/fly/hud_features.dart index db191dd..71f5aba 100644 --- a/Fly App/lib/fly/hud_features.dart +++ b/Fly App/lib/fly/hud_features.dart @@ -63,6 +63,9 @@ abstract final class HudFeatures { 'camera.focus', 'Tap-to-focus', 'Camera', (ProductProfile p) => p.camera?.adjustableFocalPointSupported == true); static final HudFeature playback = HudFeature('camera.playback', 'Playback button', 'Camera', (ProductProfile p) => p.camera?.mediaDownloadSupported == true || p.camera?.playbackSupported == true); + /// RTMP live streaming of the main camera's feed (plan P9). + static final HudFeature liveStream = HudFeature( + 'camera.liveStream', 'Live streaming (RTMP)', 'Camera', (ProductProfile p) => p.hasCamera); static final HudFeature histogram = HudFeature( 'camera.histogram', 'Histogram', 'Camera', (ProductProfile p) => p.hasCamera); @@ -168,6 +171,7 @@ abstract final class HudFeatures { spotMetering, tapFocus, playback, + liveStream, histogram, gimbalPitch, gimbalYaw, diff --git a/Fly App/lib/fly/preflight_checklist.dart b/Fly App/lib/fly/preflight_checklist.dart new file mode 100644 index 0000000..39ffbb2 --- /dev/null +++ b/Fly App/lib/fly/preflight_checklist.dart @@ -0,0 +1,108 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// One thing the pilot checks by hand before a flight (plan P9 pre-flight +/// checklist). The aircraft's own checks are `AircraftChecklist`'s live rows. +@immutable +class PreflightItem { + const PreflightItem(this.id, this.group, this.title, this.detail); + + final String id; + final String group; + final String title; + final String detail; +} + +/// The manual checks, in walk-around order. +const List preflightItems = [ + PreflightItem('site', 'Site', 'Airspace and site checked', + 'Fly zones on the map, local restrictions, permission for the take-off spot, and people who are not part of the flight kept clear.'), + PreflightItem('weather', 'Site', 'Weather within limits', + 'Wind below the aircraft\'s limit at height, no rain or fog, and enough daylight for the whole flight.'), + PreflightItem('takeoffArea', 'Site', 'Take-off area clear', 'Flat, open, away from metal and magnetic interference, with room to land.'), + PreflightItem('props', 'Aircraft', 'Propellers secure and undamaged', 'No nicks or cracks; each one locked on the right motor.'), + PreflightItem('frame', 'Aircraft', 'Arms unfolded, gimbal clamp off', 'Frame undamaged, lens and vision sensors clean.'), + PreflightItem('batteries', 'Aircraft', 'Batteries charged and seated', + 'Aircraft battery clicked in and warm enough; remote controller and phone charged.'), + PreflightItem('sdcard', 'Aircraft', 'SD card in, with space', 'Format it in the camera if it is full of old flights you have already saved.'), + PreflightItem('operatorId', 'Paperwork', 'Operator ID on the aircraft', 'Your registration number is displayed and your certificate and insurance are valid.'), + PreflightItem('rth', 'Plan', 'Return-to-home height set', 'Above the tallest obstacle between the flight area and home.'), + PreflightItem('crew', 'Plan', 'Observer briefed, phone on flight mode', 'Everyone involved knows the plan, and calls cannot cover the live view.'), +]; + +/// The pilot's ticks for the current session, kept on the phone so a restart +/// mid-preparation keeps them. A session goes stale after [ttl]: the next +/// flight day starts unticked. +class PreflightChecklist extends ChangeNotifier { + PreflightChecklist({ + DateTime Function()? clock, + this.ttl = const Duration(hours: 6), + Future Function()? prefs, + this.items = preflightItems, + }) : _clock = clock ?? DateTime.now, + _prefs = prefs ?? SharedPreferences.getInstance; + + static const String _key = 'fly.preflightChecklist'; + + final DateTime Function() _clock; + final Duration ttl; + final Future Function() _prefs; + final List items; + + Set _ticked = {}; + DateTime? _startedAt; + + bool isTicked(PreflightItem i) => _ticked.contains(i.id); + int get done => items.where(isTicked).length; + bool get complete => done == items.length; + + /// The groups in order of first appearance. + List get groups => {for (final PreflightItem i in items) i.group}.toList(); + + Future load() async { + try { + final String? raw = (await _prefs()).getString(_key); + final Object? j = raw == null ? null : jsonDecode(raw); + if (j is! Map) return; + final Object? at = j['at']; + final DateTime? started = at is num ? DateTime.fromMillisecondsSinceEpoch(at.toInt()) : null; + if (started == null || _clock().difference(started) > ttl) return; + _startedAt = started; + _ticked = {for (final Object? id in (j['ticked'] as List? ?? const [])) if (id is String) id}; + notifyListeners(); + } catch (_) { + // Nothing saved: start unticked. + } + } + + void toggle(PreflightItem i) { + final DateTime now = _clock(); + if (_startedAt == null || now.difference(_startedAt!) > ttl) { + _ticked = {}; + _startedAt = now; + } + if (!_ticked.remove(i.id)) _ticked.add(i.id); + notifyListeners(); + _save(); + } + + void reset() { + _ticked = {}; + _startedAt = null; + notifyListeners(); + _save(); + } + + Future _save() async { + try { + final SharedPreferences p = await _prefs(); + if (_startedAt == null) { + await p.remove(_key); + } else { + await p.setString(_key, jsonEncode({'at': _startedAt!.millisecondsSinceEpoch, 'ticked': _ticked.toList()..sort()})); + } + } catch (_) {} + } +} diff --git a/Fly App/lib/fly/settings/settings_tabs.dart b/Fly App/lib/fly/settings/settings_tabs.dart index 380d389..2976510 100644 --- a/Fly App/lib/fly/settings/settings_tabs.dart +++ b/Fly App/lib/fly/settings/settings_tabs.dart @@ -108,6 +108,7 @@ abstract final class SettingsTabs { static const String flyZoneLicences = 'flyZoneLicences'; static const String flyZoneDatabase = 'flyZoneDatabase'; static const String clearRoute = 'clearRoute'; + static const String liveStream = 'liveStream'; static const String about = 'about'; static const String developer = 'developer'; @@ -511,6 +512,8 @@ abstract final class SettingsTabs { const GroupSpec('MAP'), const CustomSpec(id: showRoute), const CustomSpec(id: clearRoute), + GroupSpec('LIVE STREAMING', requires: HudFeatures.liveStream), + CustomSpec(id: liveStream, requires: HudFeatures.liveStream), GroupSpec('ON-SCREEN STICKS', requires: HudFeatures.virtualSticks), CustomSpec(id: stickPads, requires: HudFeatures.virtualSticks), const GroupSpec('ABOUT'), diff --git a/Fly App/lib/fly/state/live_stream_state.dart b/Fly App/lib/fly/state/live_stream_state.dart new file mode 100644 index 0000000..1b839db --- /dev/null +++ b/Fly App/lib/fly/state/live_stream_state.dart @@ -0,0 +1,101 @@ +import 'package:flutter/foundation.dart'; + +import 'parse.dart'; + +/// RTMP live streaming (plan P9), from the bridge's `liveStream` events. The +/// stream is app-level in the SDK, so it is not cleared on disconnect: the +/// next event says whether it survived. +class LiveStreamState extends ChangeNotifier { + bool streaming = false; + + /// The last `OnLiveChangeListener` status: `STREAMING`, `STOPPED`, `ERROR`, or null. + String? status; + String? url; + int videoKbps = 0; + int audioKbps = 0; + double fps = 0; + + /// How long it has been streaming, as the SDK counts it. + int seconds = 0; + + /// Frames waiting to be sent; a growing cache means the uplink cannot keep up. + int cacheSize = 0; + String? error; + + /// The uplink is behind (more than ~2 s of video cached). + bool get congested => streaming && cacheSize > 60; + + void apply(Map e) { + streaming = asBool(e['streaming']) ?? false; + status = asString(e['status']); + url = asString(e['url']); + videoKbps = asInt(e['videoKbps']) ?? 0; + audioKbps = asInt(e['audioKbps']) ?? 0; + fps = finite(e['fps']) ?? 0; + seconds = asInt(e['seconds']) ?? 0; + cacheSize = asInt(e['cacheSize']) ?? 0; + error = asString(e['error']); + notifyListeners(); + } + + /// `1:05`, `1:02:09`. + String get elapsedLabel { + final int h = seconds ~/ 3600, m = (seconds % 3600) ~/ 60, s = seconds % 60; + final String ss = s.toString().padLeft(2, '0'); + return h > 0 ? '$h:${m.toString().padLeft(2, '0')}:$ss' : '$m:$ss'; + } + + /// `2.4 Mbps`, `850 kbps`. + String get bitrateLabel { + final int total = videoKbps + audioKbps; + return total >= 1000 ? '${(total / 1000).toStringAsFixed(1)} Mbps' : '$total kbps'; + } +} + +/// The aircraft's own video player (plan P9 in-FPV playback), from +/// `mediaPlayback` events. +@immutable +class MediaPlayback { + const MediaPlayback({this.index = -1, this.status = 'STOPPED', this.position = 0}); + + factory MediaPlayback.fromEvent(Map e) => MediaPlayback( + index: asInt(e['index']) ?? -1, + status: asString(e['status']) ?? 'UNKNOWN', + position: finite(e['position']) ?? 0, + ); + + /// Index of the playing file in the last `mediaList`, or -1. + final int index; + + /// `PLAYING`, `PAUSED`, `STOPPED` or `UNKNOWN`. + final String status; + + /// Seconds into the file. + final double position; + + bool get playing => status == 'PLAYING'; + bool get paused => status == 'PAUSED'; + + @override + bool operator ==(Object other) => + other is MediaPlayback && other.index == index && other.status == status && other.position == position; + + @override + int get hashCode => Object.hash(index, status, position); +} + +/// The stream address with its key hidden (the last path segment is the +/// platform's secret): `rtmp://a.rtmp.youtube.com/live2/•••x9`. +String maskStreamUrl(String url) { + final int slash = url.lastIndexOf('/'); + if (slash < 0 || slash < url.indexOf('://') + 3 || slash == url.length - 1) return url; + final String key = url.substring(slash + 1); + final String tail = key.length > 4 ? key.substring(key.length - 2) : ''; + return '${url.substring(0, slash + 1)}•••$tail'; +} + +/// Whether [url] is an address the SDK will stream to. +bool validStreamUrl(String url) { + final Uri? u = Uri.tryParse(url.trim()); + return u != null && (u.scheme == 'rtmp' || u.scheme == 'rtmps') && u.host.isNotEmpty && u.pathSegments.where((String s) => s.isNotEmpty).isNotEmpty; +} diff --git a/Fly App/lib/fly/telemetry_reducer.dart b/Fly App/lib/fly/telemetry_reducer.dart index c201e74..318a864 100644 --- a/Fly App/lib/fly/telemetry_reducer.dart +++ b/Fly App/lib/fly/telemetry_reducer.dart @@ -1,6 +1,7 @@ import 'dart:ui' show Size; import '../flight_model.dart'; +import 'state/live_stream_state.dart' show MediaPlayback; import 'state/parse.dart'; /// Routes the bridge's telemetry-bearing events into [FlightModel]'s sub-models @@ -58,6 +59,13 @@ class TelemetryReducer { case 'flyZoneDbProgress': model.flyZones.applyUpgradeProgress(finite(event['progress'])); return true; + case 'liveStream': + // App-level in the SDK: a stream can outlive the connection. + model.liveStream.apply(event); + return true; + case 'mediaPlayback': + model.mediaPlayback.value = MediaPlayback.fromEvent(event); + return true; case 'videoSize': final int? w = asInt(event['width']), h = asInt(event['height']); if (w != null && h != null && w > 0 && h > 0) model.videoSize.value = Size(w.toDouble(), h.toDouble()); diff --git a/Fly App/lib/main.dart b/Fly App/lib/main.dart index 2247d92..4b10b76 100644 --- a/Fly App/lib/main.dart +++ b/Fly App/lib/main.dart @@ -6,7 +6,12 @@ import 'package:geolocator/geolocator.dart'; import 'dji_service.dart'; import 'flight_model.dart'; +import 'fly/find_my_drone.dart'; +import 'fly/flight_log.dart'; +import 'fly/flight_record.dart'; +import 'fly/flight_record_sync.dart'; import 'fly/fly_prefs.dart'; +import 'fly/preflight_checklist.dart'; import 'fly/product_profile.dart'; import 'fly/telemetry_reducer.dart'; import 'login_page.dart'; @@ -55,6 +60,13 @@ class _HomePageState extends State { final DjiService _dji = DjiService(); final FlightModel _model = FlightModel(); late final TelemetryReducer _telemetry = TelemetryReducer(_model); + + // Flight records and the last known position (plan P9), app-wide: a flight + // is recorded whichever screen is showing. + final FlightLog _flightLog = FlightLog(store: FileFlightRecordStore(), remote: ApiLogbookRemote()); + late final FlightRecorder _recorder; + late final AircraftLocator _locator = AircraftLocator(model: _model); + final PreflightChecklist _checklist = PreflightChecklist(); StreamSubscription>? _sub; StreamSubscription? _authSub; StreamSubscription? _phoneLocSub; @@ -76,9 +88,17 @@ class _HomePageState extends State { _model.upload = s; _model.bump(); }); - // Rebuild when the session changes so the user panel reflects sign-in/out. + // Rebuild when the session changes so the user panel reflects sign-in/out; + // a sign-in also sends the flights recorded while signed out. _authSub = auth.status.listen((_) { if (mounted) setState(() {}); + if (auth.isAuthed && FlyPrefs.autoUploadFlights.value) _flightLog.uploadPending(); + }); + _recorder = FlightRecorder(model: _model, onFinished: _onFlightFinished); + _locator.load(); + _checklist.load(); + _flightLog.load().then((_) { + if (auth.isAuthed && FlyPrefs.autoUploadFlights.value) _flightLog.uploadPending(); }); _init(); _startPhoneLocation(); @@ -199,6 +219,7 @@ class _HomePageState extends State { .map((dynamic e) => MediaItem.fromMap(Map.from(e as Map))) .toList(); _model.mediaLoading = false; + _model.mediaVideoPlayback = event['videoPlayback'] as bool? ?? false; _model.bump(); break; case 'mediaDownload': @@ -212,6 +233,15 @@ class _HomePageState extends State { } } + Future _onFlightFinished(FlightRecord r) async { + await _flightLog.add(r); + if (r.uploaded) { + _snack('Flight logged: ${r.durationLabel}'); + } else if (r.uploadError != null && !r.simulated && auth.isAuthed) { + _snack('Flight saved on the phone; logbook upload failed: ${r.uploadError}'); + } + } + void _clearTelemetry() { _model.clearTelemetry(); _telemetry.reset(); @@ -325,6 +355,10 @@ class _HomePageState extends State { _phoneLocSub?.cancel(); _uploadSub?.cancel(); _uploader.dispose(); + _recorder.dispose(); + _locator.dispose(); + _flightLog.dispose(); + _checklist.dispose(); _serverHost.dispose(); _model.dispose(); super.dispose(); @@ -376,13 +410,15 @@ class _HomePageState extends State { _openAlbum(); break; case 'Academy': - Navigator.of(context).push(MaterialPageRoute(builder: (_) => const AcademyPage())); + Navigator.of(context).push(MaterialPageRoute(builder: (_) => AcademyPage(model: _model, checklist: _checklist))); break; case 'Routes': Navigator.of(context).push(MaterialPageRoute(builder: (_) => const RoutesPage())); break; case 'Flight logs': - Navigator.of(context).push(MaterialPageRoute(builder: (_) => const FlightLogsPage())); + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => FlightLogsPage(log: _flightLog, locator: _locator, model: _model), + )); break; default: _snack('$tile — coming soon'); @@ -398,6 +434,8 @@ class _HomePageState extends State { dji: _dji, onAppSettings: _openSettings, onSignIn: _openLogin, + log: _flightLog, + locator: _locator, ), )); } diff --git a/Fly App/lib/ui/academy_page.dart b/Fly App/lib/ui/academy_page.dart index 27e8238..1a4c03e 100644 --- a/Fly App/lib/ui/academy_page.dart +++ b/Fly App/lib/ui/academy_page.dart @@ -1,18 +1,27 @@ import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../flight_model.dart'; +import '../fly/academy_content.dart'; +import '../fly/aircraft_checklist.dart'; +import '../fly/preflight_checklist.dart'; import '../theme.dart'; +import 'flight_logs_page.dart' show PageHeader; import 'pv_icons.dart'; -/// Academy — mirrors the v2 "Academy" mockup. A featured card plus a list of -/// tutorial / manual / tips entries that open reference material in the browser. +/// Academy — mirrors the v2 "Academy" mockup (plan P9): the pre-flight +/// checklist as the featured card, the in-app guides ([academyArticles]), +/// which work without a connection in the field, and DJI's own material in +/// the browser. class AcademyPage extends StatelessWidget { - const AcademyPage({super.key}); + const AcademyPage({super.key, required this.model, required this.checklist}); - static const List<(String, String, String, String)> _cards = <(String, String, String, String)>[ - ('academy', 'Flight tutorials', '6 lessons', 'https://www.dji.com/dk/dji-fly'), - ('shield', 'Product manuals', 'Air 3 · RC 2', 'https://www.pix-pro.com/blog/dji-fly-guide-part1'), - ('sun', 'Flight tips', 'Wind & weather', 'https://www.skyzr.com/en/dji/dji-fly-app/the-ultimate-dji-fly-app-guide/'), + final FlightModel model; + final PreflightChecklist checklist; + + static const List<(String, String, String)> _links = <(String, String, String)>[ + ('academy', 'DJI product manuals', 'https://www.dji.com/support'), + ('shield', 'EU drone rules (EASA)', 'https://www.easa.europa.eu/en/domains/drones-air-mobility'), ]; Future _open(BuildContext context, String url) async { @@ -24,6 +33,8 @@ class AcademyPage extends StatelessWidget { } } + void _push(BuildContext context, Widget page) => Navigator.of(context).push(MaterialPageRoute(builder: (_) => page)); + @override Widget build(BuildContext context) { final PVScheme s = PVScheme.of(context); @@ -31,74 +42,266 @@ class AcademyPage extends StatelessWidget { backgroundColor: s.bgApp, body: SafeArea( child: ListView( + padding: const EdgeInsets.only(bottom: 20), children: [ - Padding( - padding: const EdgeInsets.fromLTRB(12, 6, 20, 14), - child: Row(children: [ - IconButton(onPressed: () => Navigator.of(context).maybePop(), icon: PVIcon('chevronLeft', size: 24, color: s.textSecondary)), - Text('Academy', style: TextStyle(fontFamily: PV.fontSans, fontSize: 20, fontWeight: FontWeight.w700, letterSpacing: -0.4, color: s.textPrimary)), - ]), - ), - GestureDetector( - onTap: () => _open(context, 'https://www.dji.com/dk/dji-fly'), - child: Container( - height: 132, - margin: const EdgeInsets.fromLTRB(20, 0, 20, 16), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - border: Border.all(color: s.border), - gradient: const LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xFF26406E), Color(0xFF0F1E3D)]), - ), - child: Stack(children: [ - const Positioned( - left: 16, bottom: 16, right: 64, - child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Text('GETTING STARTED', style: TextStyle(fontFamily: PV.fontMono, fontSize: 10, letterSpacing: 1.4, color: Color(0xB3EAF0FA))), - SizedBox(height: 3), - Text('Your first flight in 5 minutes', style: TextStyle(fontFamily: PV.fontSans, fontSize: 17, fontWeight: FontWeight.w700, color: Color(0xFFEAF0FA))), - ]), + const PageHeader(title: 'Academy'), + ListenableBuilder( + listenable: checklist, + builder: (BuildContext context, _) => GestureDetector( + key: const ValueKey('academy-checklist'), + onTap: () => _push(context, PreflightChecklistPage(model: model, checklist: checklist)), + child: Container( + height: 132, + margin: const EdgeInsets.fromLTRB(20, 0, 20, 16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all(color: s.border), + gradient: const LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xFF26406E), Color(0xFF0F1E3D)]), ), - Positioned( - top: 16, right: 16, - child: Container( - width: 40, height: 40, - decoration: const BoxDecoration(color: Color(0x29FFFFFF), shape: BoxShape.circle), - alignment: Alignment.center, - child: const PVIcon('play', size: 18, color: Colors.white, fill: true), + child: Stack(children: [ + Positioned( + left: 16, bottom: 16, right: 64, + child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + const Text('BEFORE EVERY FLIGHT', style: TextStyle(fontFamily: PV.fontMono, fontSize: 10, letterSpacing: 1.4, color: Color(0xB3EAF0FA))), + const SizedBox(height: 3), + const Text('Pre-flight checklist', style: TextStyle(fontFamily: PV.fontSans, fontSize: 17, fontWeight: FontWeight.w700, color: Color(0xFFEAF0FA))), + const SizedBox(height: 3), + Text( + checklist.done == 0 ? '${checklist.items.length} checks' : '${checklist.done} of ${checklist.items.length} done', + style: const TextStyle(fontFamily: PV.fontMono, fontSize: 11, color: Color(0xB3EAF0FA)), + ), + ]), ), - ), - ]), - ), - ), - for (final (String ic, String t, String sub, String url) in _cards) - Padding( - padding: const EdgeInsets.fromLTRB(20, 0, 20, 10), - child: GestureDetector( - onTap: () => _open(context, url), - child: Container( - height: 62, - padding: const EdgeInsets.symmetric(horizontal: 14), - decoration: BoxDecoration(color: s.surface, borderRadius: BorderRadius.circular(14), border: Border.all(color: s.border), boxShadow: s.shadowXs), - child: Row(children: [ - Container( - width: 38, height: 38, - decoration: BoxDecoration(color: s.accentSoft, borderRadius: BorderRadius.circular(10)), + Positioned( + top: 16, right: 16, + child: Container( + width: 40, height: 40, + decoration: const BoxDecoration(color: Color(0x29FFFFFF), shape: BoxShape.circle), alignment: Alignment.center, - child: PVIcon(ic, size: 19, color: s.accentSoftFg), + child: PVIcon(checklist.complete ? 'check' : 'checklist', size: 18, color: Colors.white), ), - const SizedBox(width: 12), - Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ - Text(t, style: TextStyle(fontFamily: PV.fontSans, fontSize: 14.5, fontWeight: FontWeight.w600, color: s.textPrimary)), - Text(sub, style: TextStyle(fontFamily: PV.fontMono, fontSize: 11, color: s.textSecondary)), - ])), - PVIcon('chevronRight', size: 18, color: s.textTertiary), - ]), - ), + ), + ]), ), ), + ), + _label(s, 'GUIDES'), + for (final AcademyArticle a in academyArticles) + _card(s, a.icon, a.title, '${a.subtitle} · ${a.minutes} min', () => _push(context, AcademyArticlePage(article: a)), + key: ValueKey('article-${a.id}')), + _label(s, 'ONLINE'), + for (final (String ic, String t, String url) in _links) _card(s, ic, t, Uri.parse(url).host, () => _open(context, url)), + ], + ), + ), + ); + } + + static Widget _label(PVScheme s, String text) => Padding( + padding: const EdgeInsets.fromLTRB(22, 6, 20, 8), + child: Text(text, style: TextStyle(fontFamily: PV.fontMono, fontSize: 10.5, letterSpacing: 1.2, color: s.textTertiary)), + ); + + static Widget _card(PVScheme s, String icon, String title, String sub, VoidCallback onTap, {Key? key}) => Padding( + key: key, + padding: const EdgeInsets.fromLTRB(20, 0, 20, 10), + child: GestureDetector( + onTap: onTap, + child: Container( + height: 62, + padding: const EdgeInsets.symmetric(horizontal: 14), + decoration: BoxDecoration(color: s.surface, borderRadius: BorderRadius.circular(14), border: Border.all(color: s.border), boxShadow: s.shadowXs), + child: Row(children: [ + Container( + width: 38, height: 38, + decoration: BoxDecoration(color: s.accentSoft, borderRadius: BorderRadius.circular(10)), + alignment: Alignment.center, + child: PVIcon(icon, size: 19, color: s.accentSoftFg), + ), + const SizedBox(width: 12), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ + Text(title, style: TextStyle(fontFamily: PV.fontSans, fontSize: 14.5, fontWeight: FontWeight.w600, color: s.textPrimary)), + Text(sub, overflow: TextOverflow.ellipsis, style: TextStyle(fontFamily: PV.fontMono, fontSize: 11, color: s.textSecondary)), + ])), + PVIcon('chevronRight', size: 18, color: s.textTertiary), + ]), + ), + ), + ); +} + +/// One guide, for reading. +class AcademyArticlePage extends StatelessWidget { + const AcademyArticlePage({super.key, required this.article}); + + final AcademyArticle article; + + @override + Widget build(BuildContext context) { + final PVScheme s = PVScheme.of(context); + final TextStyle body = TextStyle(fontFamily: PV.fontSans, fontSize: 15, height: 1.45, color: s.textPrimary); + return Scaffold( + backgroundColor: s.bgApp, + body: SafeArea( + child: ListView( + padding: const EdgeInsets.only(bottom: 32), + children: [ + PageHeader(title: article.title), + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 8), + child: Text('${article.subtitle} · ${article.minutes} min read', style: TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: s.textSecondary)), + ), + for (final AcademySection sec in article.sections) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(24, 18, 24, 6), + child: Text(sec.heading, style: TextStyle(fontFamily: PV.fontSans, fontSize: 17, fontWeight: FontWeight.w700, color: s.textPrimary)), + ), + for (final String p in sec.paragraphs) + Padding( + padding: const EdgeInsets.fromLTRB(24, 4, 24, 4), + child: p.startsWith('• ') + ? Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding(padding: const EdgeInsets.only(top: 8, right: 10), child: Container(width: 5, height: 5, decoration: BoxDecoration(color: s.accent, shape: BoxShape.circle))), + Expanded(child: Text(p.substring(2), style: body)), + ]) + : Text(p, style: body), + ), + ], ], ), ), ); } } + +/// The pre-flight checklist (plan P9): the aircraft's own live checks when one +/// is connected (the Aircraft Status rows), then the pilot's manual checks, +/// ticked by hand and kept for the session. +class PreflightChecklistPage extends StatelessWidget { + const PreflightChecklistPage({super.key, required this.model, required this.checklist}); + + final FlightModel model; + final PreflightChecklist checklist; + + @override + Widget build(BuildContext context) { + final PVScheme s = PVScheme.of(context); + return Scaffold( + backgroundColor: s.bgApp, + body: SafeArea( + child: ListenableBuilder( + listenable: Listenable.merge([checklist, model]), + builder: (BuildContext context, _) { + final List live = model.connected ? AircraftChecklist.build(model) : const []; + return ListView(padding: const EdgeInsets.only(bottom: 24), children: [ + PageHeader( + title: 'Pre-flight checklist', + trailing: TextButton( + key: const ValueKey('checklist-reset'), + onPressed: checklist.done == 0 ? null : checklist.reset, + child: const Text('Reset'), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 8), + child: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: checklist.done / checklist.items.length, + minHeight: 6, + backgroundColor: s.surfaceInset, + color: checklist.complete ? s.success : s.accent, + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(22, 0, 20, 4), + child: Text( + checklist.complete ? 'All checks done. Fly safe.' : '${checklist.done} of ${checklist.items.length} checks done', + style: TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: checklist.complete ? s.successFg : s.textSecondary), + ), + ), + _group(s, 'AIRCRAFT · LIVE'), + if (live.isEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(22, 0, 20, 8), + child: Text('Connect the aircraft to see its own checks here.', style: TextStyle(fontFamily: PV.fontSans, fontSize: 13, color: s.textTertiary)), + ) + else + for (final ChecklistItem i in live) _liveRow(s, i), + for (final String g in checklist.groups) ...[ + _group(s, g.toUpperCase()), + for (final PreflightItem i in checklist.items.where((PreflightItem x) => x.group == g)) _manualRow(s, i), + ], + ]); + }, + ), + ), + ); + } + + static Widget _group(PVScheme s, String text) => Padding( + padding: const EdgeInsets.fromLTRB(22, 16, 20, 6), + child: Text(text, style: TextStyle(fontFamily: PV.fontMono, fontSize: 10.5, letterSpacing: 1.2, color: s.textTertiary)), + ); + + Widget _liveRow(PVScheme s, ChecklistItem i) { + final Color c = switch (i.state) { + CheckState.ok => s.success, + CheckState.caution => s.warning, + CheckState.danger => s.danger, + CheckState.unknown => s.textTertiary, + }; + return Padding( + padding: const EdgeInsets.fromLTRB(22, 5, 20, 5), + child: Row(children: [ + Container(width: 9, height: 9, decoration: BoxDecoration(color: c, shape: BoxShape.circle)), + const SizedBox(width: 12), + Expanded(child: Text(i.label, style: TextStyle(fontFamily: PV.fontSans, fontSize: 14, color: s.textPrimary))), + Flexible( + child: Text(i.value, textAlign: TextAlign.right, overflow: TextOverflow.ellipsis, style: TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: s.textSecondary)), + ), + ]), + ); + } + + Widget _manualRow(PVScheme s, PreflightItem i) { + final bool on = checklist.isTicked(i); + return InkWell( + key: ValueKey('check-${i.id}'), + onTap: () => checklist.toggle(i), + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 8), + child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Container( + width: 22, + height: 22, + margin: const EdgeInsets.only(top: 1), + decoration: BoxDecoration( + color: on ? s.accent : Colors.transparent, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: on ? s.accent : s.borderStrong, width: 1.6), + ), + alignment: Alignment.center, + child: on ? const PVIcon('check', size: 14, stroke: 2.6, color: Colors.white) : null, + ), + const SizedBox(width: 12), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(i.title, + style: TextStyle( + fontFamily: PV.fontSans, + fontSize: 14.5, + fontWeight: FontWeight.w600, + color: on ? s.textSecondary : s.textPrimary, + decoration: on ? TextDecoration.lineThrough : null, + )), + const SizedBox(height: 2), + Text(i.detail, style: TextStyle(fontFamily: PV.fontSans, fontSize: 12.5, height: 1.35, color: s.textSecondary)), + ]), + ), + ]), + ), + ); + } +} diff --git a/Fly App/lib/ui/album_page.dart b/Fly App/lib/ui/album_page.dart index 759bce6..e47921e 100644 --- a/Fly App/lib/ui/album_page.dart +++ b/Fly App/lib/ui/album_page.dart @@ -5,27 +5,83 @@ import 'package:flutter/services.dart'; import '../dji_service.dart'; import '../flight_model.dart'; +import '../fly/state/live_stream_state.dart' show MediaPlayback; import '../theme.dart'; import 'pv_icons.dart'; -/// Portrait media grid — mirrors the v2 "Album" mockup. Backed by the aircraft's -/// SD card through the SDK MediaManager: refreshes the list on open, lazily -/// fetches thumbnails, and downloads originals on tap. Falls back to an empty -/// state when no aircraft/media is present. +/// The media grid — mirrors the v2 "Album" mockup — and GO 4's in-FPV playback +/// (plan P9). Backed by the aircraft's SD card through the SDK MediaManager: +/// refreshes the list on open, lazily fetches thumbnails, and lets the pilot +/// view, download and delete files (long press selects several). +/// +/// Listing puts the camera in download mode; leaving puts it back in the +/// capture mode the flight screen was in. +/// +/// Opened from the flight screen ([AlbumPage.overVideo], through [route]) the +/// page is a see-through route: a video then plays *on the aircraft*, into the +/// live feed underneath, with the transport controls over it. class AlbumPage extends StatefulWidget { - const AlbumPage({super.key, required this.model, required this.dji}); + const AlbumPage({super.key, required this.model, required this.dji, this.overVideo = false}); final FlightModel model; final DjiService dji; + /// Sits over the flight screen's live video, which can then show the + /// aircraft's own video playback. + final bool overVideo; + + /// The route the flight screen opens: not opaque, so the live view stays + /// mounted (and visible while a video plays) underneath. + static Route route({required FlightModel model, required DjiService dji}) => PageRouteBuilder( + opaque: false, + transitionDuration: const Duration(milliseconds: 180), + pageBuilder: (BuildContext context, _, __) => AlbumPage(model: model, dji: dji, overVideo: true), + transitionsBuilder: (BuildContext context, Animation a, _, Widget child) => FadeTransition(opacity: a, child: child), + ); + @override State createState() => _AlbumPageState(); } +/// The grid's filter chips. +enum AlbumFilter { + all('All'), + photos('Photos'), + videos('Videos'), + pano('Pano'); + + const AlbumFilter(this.label); + final String label; + + bool accepts(MediaItem m) => switch (this) { + AlbumFilter.all => true, + AlbumFilter.photos => !m.isVideo && !m.isPano, + AlbumFilter.videos => m.isVideo, + AlbumFilter.pano => m.isPano, + }; +} + +/// An item's identity across re-listing: its key, else its index. +String mediaId(MediaItem m) => m.key ?? 'i${m.index}'; + class _AlbumPageState extends State { - static const List _filters = ['All', 'Photos', 'Videos', 'Pano']; - int _active = 0; - final Set _thumbRequested = {}; + AlbumFilter _filter = AlbumFilter.all; + + /// Thumbnails by [mediaId], kept across a re-list (a delete re-indexes everything). + final Map _thumbs = {}; + final Set _thumbRequested = {}; + + /// Selected items by [mediaId]; non-empty = selection mode. + final Set _selected = {}; + + /// What a long-running batch is doing ("Downloading 2 of 5"), or null. + String? _busy; + + /// The video playing on the aircraft, while [AlbumPage.overVideo]. + MediaItem? _playing; + + /// Whether this page put the camera in download mode (and must put it back). + bool _tookCamera = false; DjiService get _dji => widget.dji; FlightModel get _m => widget.model; @@ -36,19 +92,32 @@ class _AlbumPageState extends State { _refresh(); } + @override + void dispose() { + 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 _) {}); + } + super.dispose(); + } + Future _refresh() async { if (!_m.connected) return; setState(() => _m.mediaLoading = true); try { + _tookCamera = true; await _dji.refreshMediaList(); } catch (e) { if (mounted) { setState(() => _m.mediaLoading = false); - _snack('Media: ${e is PlatformException ? (e.message ?? e.code) : e}'); + _snack('Media: ${_reason(e)}'); } } } + static String _reason(Object e) => e is PlatformException ? (e.message ?? e.code) : e.toString(); + void _snack(String msg) { ScaffoldMessenger.of(context) ..clearSnackBars() @@ -56,44 +125,53 @@ class _AlbumPageState extends State { } Future _ensureThumb(MediaItem item) async { - if (item.thumbPath != null || _thumbRequested.contains(item.index)) return; - _thumbRequested.add(item.index); + final String id = mediaId(item); + item.thumbPath ??= _thumbs[id]; + if (item.thumbPath != null || _thumbRequested.contains(id)) return; + _thumbRequested.add(id); try { final String? path = await _dji.fetchThumbnail(item.index); - if (path != null && mounted) setState(() => item.thumbPath = path); + if (path != null && mounted) { + _thumbs[id] = path; + setState(() => item.thumbPath = path); + } } catch (_) { // leave placeholder icon } } - List get _visible { - switch (_active) { - case 1: - return _m.media.where((MediaItem m) => !m.isVideo).toList(); - case 2: - return _m.media.where((MediaItem m) => m.isVideo).toList(); - default: - return _m.media; - } - } + List get _visible => [for (final MediaItem m in _m.media) if (_filter.accepts(m)) m]; + + List get _selectedItems => [for (final MediaItem m in _m.media) if (_selected.contains(mediaId(m))) m]; + + void _toggle(MediaItem item) => setState(() { + final String id = mediaId(item); + if (!_selected.remove(id)) _selected.add(id); + }); @override Widget build(BuildContext context) { final PVScheme s = PVScheme.of(context); + if (_playing != null) return _player(_playing!); return Scaffold( backgroundColor: s.bgApp, body: SafeArea( child: AnimatedBuilder( animation: _m, - builder: (BuildContext context, _) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _header(s), - _filterBar(s), - const SizedBox(height: 14), - Expanded(child: _body(s)), - ], - ), + builder: (BuildContext context, _) { + // A re-list drops selections of files that are gone. + _selected.retainAll({for (final MediaItem m in _m.media) mediaId(m)}); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _selected.isEmpty ? _header(s) : _selectionHeader(s), + _filterBar(s), + if (_busy != null) _busyBar(s), + const SizedBox(height: 14), + Expanded(child: _body(s)), + ], + ); + }, ), ), ); @@ -109,29 +187,74 @@ class _AlbumPageState extends State { ), Text('Album', style: TextStyle(fontFamily: PV.fontSans, fontSize: 20, fontWeight: FontWeight.w700, letterSpacing: -0.4, color: s.textPrimary)), const Spacer(), - IconButton(onPressed: _refresh, icon: PVIcon('search', size: 19, color: s.textSecondary)), + IconButton( + key: const ValueKey('album-refresh'), + tooltip: 'Refresh', + onPressed: _busy == null ? _refresh : null, + icon: PVIcon('rth', size: 19, color: s.textSecondary), + ), Text('${_m.media.length} items', style: TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: s.textTertiary)), ]), ); } + Widget _selectionHeader(PVScheme s) { + final bool all = _visible.every((MediaItem m) => _selected.contains(mediaId(m))); + return Padding( + padding: const EdgeInsets.fromLTRB(12, 6, 12, 12), + child: Row(children: [ + IconButton( + key: const ValueKey('album-clear-selection'), + onPressed: () => setState(_selected.clear), + icon: PVIcon('close', size: 22, color: s.textSecondary), + ), + Text('${_selected.length} selected', + style: TextStyle(fontFamily: PV.fontSans, fontSize: 18, fontWeight: FontWeight.w700, color: s.textPrimary)), + const Spacer(), + TextButton( + onPressed: () => setState(() { + if (all) { + _selected.clear(); + } else { + _selected.addAll(_visible.map(mediaId)); + } + }), + child: Text(all ? 'None' : 'All'), + ), + IconButton( + key: const ValueKey('album-download'), + tooltip: 'Download', + onPressed: _busy == null ? () => _download(_selectedItems) : null, + icon: PVIcon('download', size: 20, color: s.textSecondary), + ), + IconButton( + key: const ValueKey('album-delete'), + tooltip: 'Delete', + onPressed: _busy == null ? () => _delete(_selectedItems) : null, + icon: PVIcon('trash', size: 20, color: s.danger), + ), + ]), + ); + } + Widget _filterBar(PVScheme s) { return SizedBox( height: 28, child: ListView.separated( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 20), - itemCount: _filters.length, + itemCount: AlbumFilter.values.length, separatorBuilder: (_, __) => const SizedBox(width: 8), itemBuilder: (BuildContext context, int i) { - final bool active = i == _active; + final AlbumFilter f = AlbumFilter.values[i]; + final bool active = f == _filter; return GestureDetector( - onTap: () => setState(() => _active = i), + onTap: () => setState(() => _filter = f), child: Container( padding: const EdgeInsets.symmetric(horizontal: 12), alignment: Alignment.center, decoration: BoxDecoration(color: active ? s.accent : s.surfaceInset, borderRadius: BorderRadius.circular(999)), - child: Text(_filters[i], + child: Text(f.label, style: TextStyle(fontFamily: PV.fontSans, fontSize: 12.5, fontWeight: FontWeight.w600, color: active ? Colors.white : s.textSecondary)), ), ); @@ -140,6 +263,15 @@ class _AlbumPageState extends State { ); } + Widget _busyBar(PVScheme s) => Padding( + padding: const EdgeInsets.fromLTRB(20, 10, 20, 0), + child: Row(children: [ + SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2, color: s.accent)), + const SizedBox(width: 10), + Text(_busy!, style: TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: s.textSecondary)), + ]), + ); + Widget _body(PVScheme s) { if (_m.mediaLoading) { return Center(child: CircularProgressIndicator(color: s.accent)); @@ -157,9 +289,10 @@ class _AlbumPageState extends State { ]), ); } + // Three across in portrait, more on the flight screen's landscape. return GridView.builder( padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3, crossAxisSpacing: 6, mainAxisSpacing: 6), + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(maxCrossAxisExtent: 140, crossAxisSpacing: 6, mainAxisSpacing: 6), itemCount: items.length, itemBuilder: (BuildContext context, int i) => _cell(s, items[i], i), ); @@ -168,8 +301,11 @@ class _AlbumPageState extends State { Widget _cell(PVScheme s, MediaItem item, int i) { _ensureThumb(item); final bool hasThumb = item.thumbPath != null && File(item.thumbPath!).existsSync(); + final bool selected = _selected.contains(mediaId(item)); return GestureDetector( - onTap: () => _open(item), + key: ValueKey('media-${mediaId(item)}'), + onTap: () => _selected.isEmpty ? _open(item) : _toggle(item), + onLongPress: () => _toggle(item), child: ClipRRect( borderRadius: BorderRadius.circular(10), child: Container( @@ -179,21 +315,35 @@ class _AlbumPageState extends State { colors: [s.surface2, s.surfaceInset], transform: GradientRotation((140 + i * 14) * 3.1415926 / 180), ), - border: Border.all(color: s.border), + border: Border.all(color: selected ? s.accent : s.border, width: selected ? 2.5 : 1), borderRadius: BorderRadius.circular(10), ), child: Stack(fit: StackFit.expand, children: [ if (hasThumb) Image.file(File(item.thumbPath!), fit: BoxFit.cover) else - Center(child: PVIcon(item.isVideo ? 'video' : 'image', size: 20, stroke: 1.6, color: s.textTertiary)), + Center(child: PVIcon(item.isVideo ? 'video' : (item.isPano ? 'pano' : 'image'), size: 20, stroke: 1.6, color: s.textTertiary)), if (item.durationLabel != null) Positioned( right: 6, bottom: 5, + child: _badge(item.durationLabel!), + ), + if (item.localPath != null) + Positioned(left: 6, bottom: 5, child: _badge('SAVED')), + if (_selected.isNotEmpty) + Positioned( + right: 6, + top: 6, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), - decoration: BoxDecoration(color: const Color(0xB30B1730), borderRadius: BorderRadius.circular(5)), - child: Text(item.durationLabel!, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 9.5, color: Colors.white)), + width: 20, + height: 20, + decoration: BoxDecoration( + color: selected ? s.accent : const Color(0x660B1730), + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 1.5), + ), + alignment: Alignment.center, + child: selected ? const PVIcon('check', size: 12, stroke: 2.4, color: Colors.white) : null, ), ), ]), @@ -202,13 +352,283 @@ class _AlbumPageState extends State { ); } + Widget _badge(String text) => Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), + decoration: BoxDecoration(color: const Color(0xB30B1730), borderRadius: BorderRadius.circular(5)), + child: Text(text, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 9.5, color: Colors.white)), + ); + + // ── Actions ───────────────────────────────────────────────────────────────── + Future _open(MediaItem item) async { - _snack('Downloading ${item.fileName}…'); + if (item.isVideo && widget.overVideo && _m.mediaVideoPlayback) { + try { + await _dji.playMediaVideo(item.index); + if (mounted) setState(() => _playing = item); + } catch (e) { + if (mounted) _snack('Playback: ${_reason(e)}'); + } + return; + } + final String? action = await Navigator.of(context).push(MaterialPageRoute( + builder: (_) => MediaViewerPage(item: item, dji: _dji), + )); + if (!mounted) return; + if (action == 'download') await _download([item]); + if (action == 'delete') await _delete([item]); + } + + /// Downloads one at a time (the SDK serves one transfer per camera). + Future _download(List items) async { + if (items.isEmpty) return; + int done = 0; + String? lastPath; try { - final String? path = await _dji.downloadMedia(item.index); - if (mounted && path != null) _snack('Saved to $path'); + for (final MediaItem item in items) { + setState(() => _busy = 'Downloading ${done + 1} of ${items.length} · ${item.fileName}'); + lastPath = await _dji.downloadMedia(item.index); + item.localPath = lastPath ?? item.localPath; + done++; + } + if (mounted) { + setState(_selected.clear); + _snack(items.length == 1 && lastPath != null ? 'Saved to $lastPath' : 'Saved $done files to the phone'); + } } catch (e) { - if (mounted) _snack('Download: ${e is PlatformException ? (e.message ?? e.code) : e}'); + if (mounted) _snack('Download stopped after $done of ${items.length}: ${_reason(e)}'); + } finally { + if (mounted) setState(() => _busy = null); } } + + Future _delete(List items) async { + if (items.isEmpty) return; + final bool? ok = await showDialog( + context: context, + builder: (BuildContext context) => AlertDialog( + title: Text(items.length == 1 ? 'Delete ${items.single.fileName}?' : 'Delete ${items.length} files?'), + content: const Text('They are erased from the aircraft\'s SD card. Files already saved to this phone stay.'), + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel')), + TextButton( + key: const ValueKey('album-delete-confirm'), + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Delete'), + ), + ], + ), + ); + if (ok != true || !mounted) return; + setState(() => _busy = 'Deleting ${items.length} ${items.length == 1 ? 'file' : 'files'}…'); + try { + final int failed = await _dji.deleteMedia([for (final MediaItem m in items) m.index]); + if (mounted) { + setState(_selected.clear); + _snack(failed == 0 ? 'Deleted' : 'The camera kept $failed of ${items.length} files'); + } + } catch (e) { + if (mounted) _snack('Delete: ${_reason(e)}'); + } finally { + if (mounted) setState(() => _busy = null); + } + } + + // ── Video playback on the aircraft (over the live view) ───────────────────── + + Future _transport(Future Function() call, String label) async { + try { + await call(); + } catch (e) { + if (mounted) _snack('$label: ${_reason(e)}'); + } + } + + Future _closePlayer() async { + await _transport(_dji.stopMediaVideo, 'Stop'); + if (mounted) setState(() => _playing = null); + } + + Widget _player(MediaItem item) { + final double duration = (item.durationSeconds ?? 0).toDouble(); + return PopScope( + canPop: false, + onPopInvokedWithResult: (bool didPop, _) { + if (!didPop) _closePlayer(); + }, + child: Scaffold( + backgroundColor: Colors.transparent, + body: SafeArea( + child: Column(children: [ + Container( + color: const Color(0x99081020), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Row(children: [ + IconButton( + key: const ValueKey('player-close'), + onPressed: _closePlayer, + icon: const PVIcon('chevronLeft', size: 22, color: Glass.ink), + ), + Expanded( + child: Text(item.fileName, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontFamily: PV.fontMono, fontSize: 13, color: Glass.ink)), + ), + const Text('PLAYING ON AIRCRAFT', style: TextStyle(fontFamily: PV.fontMono, fontSize: 10, letterSpacing: 1, color: Color(0x99EAF0FA))), + ]), + ), + const Spacer(), + ValueListenableBuilder( + valueListenable: _m.mediaPlayback, + builder: (BuildContext context, MediaPlayback p, _) { + final double pos = duration > 0 ? p.position.clamp(0, duration).toDouble() : 0; + return Container( + color: const Color(0x99081020), + padding: const EdgeInsets.fromLTRB(8, 4, 16, 4), + child: Row(children: [ + IconButton( + key: const ValueKey('player-toggle'), + onPressed: () => p.playing + ? _transport(_dji.pauseMediaVideo, 'Pause') + : (p.paused ? _transport(_dji.resumeMediaVideo, 'Resume') : _transport(() => _dji.playMediaVideo(item.index), 'Play')), + icon: PVIcon(p.playing ? 'pause' : 'play', size: 22, color: Glass.ink, fill: true), + ), + Text(_clock(pos), style: const TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: Glass.ink)), + Expanded( + child: Slider( + value: pos, + max: duration > 0 ? duration : 1, + onChanged: duration > 0 ? (double v) => _transport(() => _dji.seekMediaVideo(v), 'Seek') : null, + ), + ), + Text(_clock(duration), style: const TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: Glass.ink)), + ]), + ); + }, + ), + ]), + ), + ), + ); + } + + static String _clock(double seconds) { + final int s = seconds.round(); + return '${s ~/ 60}:${(s % 60).toString().padLeft(2, '0')}'; + } +} + +/// One file full-screen: the camera's preview (or the downloaded original) and +/// its details, with Download and Delete. Pops `'download'` / `'delete'` for +/// the album to carry out, so batch and single actions share one path. +class MediaViewerPage extends StatefulWidget { + const MediaViewerPage({super.key, required this.item, required this.dji}); + + final MediaItem item; + final DjiService dji; + + @override + State createState() => _MediaViewerPageState(); +} + +class _MediaViewerPageState extends State { + bool _loading = false; + String? _error; + + MediaItem get _item => widget.item; + + @override + void initState() { + super.initState(); + if (_item.localPath == null && _item.previewPath == null && !_item.isVideo) _fetchPreview(); + } + + Future _fetchPreview() async { + setState(() => _loading = true); + try { + final String? path = await widget.dji.fetchMediaPreview(_item.index); + if (mounted) setState(() => _item.previewPath = path); + } catch (e) { + if (mounted) setState(() => _error = e is PlatformException ? (e.message ?? e.code) : e.toString()); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + String? get _imagePath { + for (final String? p in [ + if (!_item.isVideo) _item.localPath, + _item.previewPath, + _item.thumbPath, + ]) { + if (p != null && File(p).existsSync()) return p; + } + return null; + } + + @override + Widget build(BuildContext context) { + final String? image = _imagePath; + final DateTime? created = _item.createdMs == null ? null : DateTime.fromMillisecondsSinceEpoch(_item.createdMs!); + final List facts = [ + if (created != null) _date(created), + if (_item.durationLabel != null) _item.durationLabel!, + if (_item.sizeLabel != null) _item.sizeLabel!, + if (_item.mediaType != null) _item.mediaType!, + ]; + return Scaffold( + backgroundColor: const Color(0xFF050A14), + body: SafeArea( + child: Column(children: [ + Row(children: [ + IconButton(onPressed: () => Navigator.of(context).pop(), icon: const PVIcon('chevronLeft', size: 22, color: Glass.ink)), + Expanded( + child: Text(_item.fileName, + overflow: TextOverflow.ellipsis, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 13, color: Glass.ink)), + ), + IconButton( + key: const ValueKey('viewer-download'), + tooltip: 'Download', + onPressed: () => Navigator.of(context).pop('download'), + icon: const PVIcon('download', size: 20, color: Glass.ink), + ), + IconButton( + key: const ValueKey('viewer-delete'), + tooltip: 'Delete', + onPressed: () => Navigator.of(context).pop('delete'), + icon: const PVIcon('trash', size: 20, color: Color(0xFFE05252)), + ), + ]), + Expanded( + child: Center( + child: _loading + ? const CircularProgressIndicator() + : image != null + ? InteractiveViewer(maxScale: 6, child: Image.file(File(image), fit: BoxFit.contain)) + : Column(mainAxisSize: MainAxisSize.min, children: [ + PVIcon(_item.isVideo ? 'video' : 'image', size: 40, stroke: 1.4, color: const Color(0x99EAF0FA)), + const SizedBox(height: 10), + Text( + _error ?? (_item.isVideo ? 'Play videos from the flight screen, or download them' : 'No preview'), + textAlign: TextAlign.center, + style: const TextStyle(fontFamily: PV.fontSans, fontSize: 13, color: Color(0x99EAF0FA)), + ), + ]), + ), + ), + Padding( + padding: const EdgeInsets.all(12), + child: Text( + facts.join(' · ') + (_item.localPath != null ? '\nSaved to ${_item.localPath}' : ''), + textAlign: TextAlign.center, + style: const TextStyle(fontFamily: PV.fontMono, fontSize: 11, color: Color(0x99EAF0FA)), + ), + ), + ]), + ), + ); + } + + static String _date(DateTime d) => + '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')} ' + '${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}'; } diff --git a/Fly App/lib/ui/find_my_drone_page.dart b/Fly App/lib/ui/find_my_drone_page.dart new file mode 100644 index 0000000..cceabe2 --- /dev/null +++ b/Fly App/lib/ui/find_my_drone_page.dart @@ -0,0 +1,251 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:latlong2/latlong.dart' show LatLng; +import 'package:url_launcher/url_launcher.dart'; + +import '../flight_model.dart'; +import '../fly/find_my_drone.dart'; +import '../fly/flight_log.dart'; +import '../fly/flight_record.dart'; +import '../theme.dart'; +import 'flight_logs_page.dart' show PageHeader; +import 'pv_icons.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, +/// the direction to walk, and a hand-off to the maps app. The last flight's +/// path is drawn as a hint of where it was heading. +class FindMyDronePage extends StatefulWidget { + const FindMyDronePage({super.key, required this.locator, required this.log, required this.model}); + + final AircraftLocator locator; + final FlightLog log; + final FlightModel model; + + @override + State createState() => _FindMyDronePageState(); +} + +class _FindMyDronePageState extends State { + StreamSubscription? _phoneSub; + LatLng? _phone; + double? _phoneHeading; + String? _phoneProblem; + final MapController _map = MapController(); + + @override + void initState() { + super.initState(); + _startPhone(); + } + + /// The home screen's phone fix is coarse (it only feeds the Web App's map); + /// walking to an aircraft needs the precise one, for as long as this is open. + Future _startPhone() async { + try { + if (!await Geolocator.isLocationServiceEnabled()) { + setState(() => _phoneProblem = 'Turn on location to see the way'); + return; + } + LocationPermission perm = await Geolocator.checkPermission(); + if (perm == LocationPermission.denied) perm = await Geolocator.requestPermission(); + if (perm == LocationPermission.denied || perm == LocationPermission.deniedForever) { + if (mounted) setState(() => _phoneProblem = 'Allow location to see the way'); + return; + } + _phoneSub = Geolocator.getPositionStream( + locationSettings: const LocationSettings(accuracy: LocationAccuracy.best, distanceFilter: 2), + ).listen((Position p) { + if (!mounted) return; + setState(() { + _phone = LatLng(p.latitude, p.longitude); + _phoneHeading = p.heading >= 0 && p.speed > 0.5 ? p.heading : null; + _phoneProblem = null; + }); + }); + } catch (_) { + if (mounted) setState(() => _phoneProblem = 'This phone\'s location is unavailable'); + } + } + + @override + void dispose() { + _phoneSub?.cancel(); + _map.dispose(); + super.dispose(); + } + + Future _navigate(LastKnownPosition p) async { + final String at = '${p.latitude},${p.longitude}'; + final Uri geo = Uri.parse('geo:$at?q=$at(${Uri.encodeComponent(p.model ?? 'Drone')})'); + if (await launchUrl(geo, mode: LaunchMode.externalApplication)) return; + await launchUrl(Uri.parse('https://www.openstreetmap.org/?mlat=${p.latitude}&mlon=${p.longitude}#map=18/${p.latitude}/${p.longitude}'), + mode: LaunchMode.externalApplication); + } + + @override + Widget build(BuildContext context) { + final PVScheme s = PVScheme.of(context); + return Scaffold( + backgroundColor: s.bgApp, + body: SafeArea( + child: ListenableBuilder( + listenable: widget.locator, + builder: (BuildContext context, _) { + final LastKnownPosition? p = widget.locator.last; + return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + const PageHeader(title: 'Find my drone'), + if (p == null) + Expanded( + child: Center( + child: Padding( + padding: const EdgeInsets.all(40), + child: Text( + 'No position recorded yet.\nThe aircraft\'s position is kept here once it has a GPS fix.', + textAlign: TextAlign.center, + style: TextStyle(fontFamily: PV.fontSans, fontSize: 14, color: s.textSecondary), + ), + ), + ), + ) + else ...[ + Expanded(child: _mapView(p)), + _panel(s, p), + ], + ]); + }, + ), + ), + ); + } + + Widget _mapView(LastKnownPosition p) { + final LatLng drone = LatLng(p.latitude, p.longitude); + final FlightRecord? last = widget.log.latest; + final List path = last == null || last.track.isEmpty || last.end.isBefore(p.at.subtract(const Duration(hours: 1))) + ? const [] + : [for (final TrackPoint t in last.track) t.latLng]; + final List fit = [drone, if (_phone != null) _phone!]; + return FlutterMap( + mapController: _map, + options: fit.length >= 2 && fit[0] != fit[1] + ? 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'), + if (path.length >= 2) PolylineLayer(polylines: [Polyline(points: path, strokeWidth: 2.5, color: const Color(0x993D7BF0))]), + if (_phone != null) + PolylineLayer(polylines: [ + Polyline(points: [_phone!, drone], strokeWidth: 2, color: const Color(0xFFE09A3E), pattern: StrokePattern.dashed(segments: const [8, 6])), + ]), + MarkerLayer(markers: [ + Marker( + point: drone, + width: 34, + height: 34, + child: Container( + key: const ValueKey('drone-marker'), + decoration: BoxDecoration(color: const Color(0xFFE05252), shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 2.5)), + alignment: Alignment.center, + child: const PVIcon('drone', size: 18, color: Colors.white), + ), + ), + if (_phone != null) + Marker( + point: _phone!, + width: 18, + height: 18, + child: Container( + decoration: BoxDecoration(color: const Color(0xFF3D7BF0), shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 2.5)), + ), + ), + ]), + ], + ); + } + + Widget _panel(PVScheme s, LastKnownPosition p) { + final bool live = widget.model.connected && DateTime.now().difference(p.at) < const Duration(seconds: 10); + final double? dist = _phone == null ? null : p.distanceFrom(_phone!.latitude, _phone!.longitude); + final double? bearing = _phone == null ? null : p.bearingFrom(_phone!.latitude, _phone!.longitude); + final List facts = [ + if (p.model != null) p.model!, + if (p.altitude != null && p.inFlight) '${p.altitude!.round()} m high', + if (p.batteryPercent != null) 'battery ${p.batteryPercent}%', + '${p.latitude.toStringAsFixed(6)}, ${p.longitude.toStringAsFixed(6)}', + ]; + return Container( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 20), + decoration: BoxDecoration(color: s.surface, border: Border(top: BorderSide(color: s.border))), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + Row(children: [ + if (bearing != null) + Padding( + padding: const EdgeInsets.only(right: 12), + // The arrow points along the bearing; with a walking heading it turns to "ahead = up". + child: Transform.rotate( + angle: (bearing - (_phoneHeading ?? 0)) * math.pi / 180, + child: CustomPaint(size: const Size(34, 34), painter: _ArrowPainter(s.accent)), + ), + ), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text( + dist == null ? (_phoneProblem ?? 'Finding this phone…') : '${distanceLabel(dist)} ${compassPoint(bearing!)}', + key: const ValueKey('find-distance'), + style: TextStyle(fontFamily: PV.fontSans, fontSize: 22, fontWeight: FontWeight.w700, color: s.textPrimary), + ), + Text( + live ? 'Live from the aircraft' : 'Last seen ${agoLabel(p.at, DateTime.now())}${p.inFlight ? ', in flight' : ''}', + style: TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: live ? s.successFg : (p.inFlight ? s.warningFg : s.textSecondary)), + ), + ]), + ), + ]), + const SizedBox(height: 8), + Text(facts.join(' · '), style: TextStyle(fontFamily: PV.fontMono, fontSize: 11, color: s.textTertiary)), + const SizedBox(height: 14), + Row(children: [ + Expanded( + child: FilledButton( + key: const ValueKey('find-navigate'), + onPressed: () => _navigate(p), + child: const Text('Navigate'), + ), + ), + const SizedBox(width: 10), + OutlinedButton( + onPressed: () => _map.move(LatLng(p.latitude, p.longitude), 18), + child: const Text('Centre'), + ), + ]), + ]), + ); + } +} + +/// An arrow pointing up (north, or straight ahead), turned by its parent. +class _ArrowPainter extends CustomPainter { + const _ArrowPainter(this.color); + + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final double w = size.width, h = size.height; + final Path p = Path() + ..moveTo(w / 2, 0) + ..lineTo(w * 0.9, h) + ..lineTo(w / 2, h * 0.72) + ..lineTo(w * 0.1, h) + ..close(); + canvas.drawPath(p, Paint()..color = color); + } + + @override + bool shouldRepaint(_ArrowPainter old) => old.color != color; +} diff --git a/Fly App/lib/ui/flight_logs_page.dart b/Fly App/lib/ui/flight_logs_page.dart index 3bfe534..a52817b 100644 --- a/Fly App/lib/ui/flight_logs_page.dart +++ b/Fly App/lib/ui/flight_logs_page.dart @@ -1,16 +1,365 @@ import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart' show LatLng; -import 'simple_list_page.dart'; +import '../flight_model.dart'; +import '../fly/find_my_drone.dart'; +import '../fly/flight_log.dart'; +import '../fly/flight_record.dart'; +import '../fly/fly_prefs.dart'; +import '../pb_auth.dart'; +import '../theme.dart'; +import 'find_my_drone_page.dart'; +import 'pv_icons.dart'; -/// Flight-log history. Per-session logging/persistence is a later add, so this -/// shows the empty state for now. +/// Flight records (plan P9): every flight the app recorded, newest first, with +/// its logbook upload state, and Find My Drone at the top. class FlightLogsPage extends StatelessWidget { - const FlightLogsPage({super.key}); + const FlightLogsPage({super.key, required this.log, required this.locator, required this.model}); + + final FlightLog log; + final AircraftLocator locator; + final FlightModel model; @override - Widget build(BuildContext context) => const SimpleListPage( - title: 'Flight logs', - emptyIcon: 'gauge', - emptyText: 'No flights recorded yet.\nYour flight history will appear here.', + Widget build(BuildContext context) { + final PVScheme s = PVScheme.of(context); + return Scaffold( + backgroundColor: s.bgApp, + body: SafeArea( + child: ListenableBuilder( + listenable: log, + builder: (BuildContext context, _) { + final List records = log.records; + final int pending = log.pending.length; + return ListView( + padding: const EdgeInsets.only(bottom: 24), + children: [ + PageHeader(title: 'Flight logs', trailing: Text('${records.length} flights', style: _mono(s, 12, s.textTertiary))), + _findCard(context, s), + if (pending > 0) _pendingCard(context, s, pending), + _autoUploadRow(s), + if (!log.loaded) + Padding(padding: const EdgeInsets.all(40), child: Center(child: CircularProgressIndicator(color: s.accent))) + else if (records.isEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(40, 48, 40, 0), + child: Column(children: [ + PVIcon('gauge', size: 40, stroke: 1.4, color: s.textTertiary), + const SizedBox(height: 12), + Text('No flights recorded yet.\nEvery flight is recorded from take-off to landing.', + textAlign: TextAlign.center, style: TextStyle(fontFamily: PV.fontSans, fontSize: 14, color: s.textSecondary)), + ]), + ) + else + for (final FlightRecord r in records) _row(context, s, r), + ], + ); + }, + ), + ), + ); + } + + Widget _findCard(BuildContext context, PVScheme s) { + return ListenableBuilder( + listenable: locator, + builder: (BuildContext context, _) { + final LastKnownPosition? p = locator.last; + return _Card( + key: const ValueKey('find-my-drone'), + icon: 'pin', + title: 'Find my drone', + subtitle: p == null ? 'No position recorded yet' : 'Last seen ${agoLabel(p.at, DateTime.now())}${p.inFlight ? ' · in flight' : ''}', + onTap: () => Navigator.of(context).push(MaterialPageRoute( + builder: (_) => FindMyDronePage(locator: locator, log: log, model: model), + )), + ); + }, + ); + } + + Widget _pendingCard(BuildContext context, PVScheme s, int pending) { + final bool signedIn = log.remote.available; + return _Card( + key: const ValueKey('upload-pending'), + icon: 'share', + title: '$pending ${pending == 1 ? 'flight' : 'flights'} not in the logbook', + subtitle: signedIn ? 'Tap to upload to PilotVault' : 'Sign in to upload them to PilotVault', + onTap: signedIn + ? () async { + final int ok = await log.uploadPending(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Uploaded $ok of $pending'))); + } + } + : null, + ); + } + + Widget _autoUploadRow(PVScheme s) => ValueListenableBuilder( + valueListenable: FlyPrefs.autoUploadFlights, + builder: (BuildContext context, bool on, _) => Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 12, 4), + child: Row(children: [ + Expanded( + child: Text('Upload each flight to the logbook when it lands', + style: TextStyle(fontFamily: PV.fontSans, fontSize: 13, color: s.textSecondary)), + ), + Switch(key: const ValueKey('auto-upload'), value: on, onChanged: FlyPrefs.setAutoUploadFlights), + ]), + ), + ); + + Widget _row(BuildContext context, PVScheme s, FlightRecord r) { + final (String label, Color color) = _status(s, r); + return Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 10), + child: GestureDetector( + key: ValueKey('flight-${r.id}'), + onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => FlightRecordPage(record: r, log: log))), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration(color: s.surface, borderRadius: BorderRadius.circular(14), border: Border.all(color: s.border), boxShadow: s.shadowXs), + child: Row(children: [ + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('${_date(r.start)} · ${_time(r.start)}–${_time(r.end)}', + style: TextStyle(fontFamily: PV.fontSans, fontSize: 14.5, fontWeight: FontWeight.w600, color: s.textPrimary)), + const SizedBox(height: 3), + Text( + '${r.aircraftModel ?? 'Aircraft'} · ${r.durationLabel} · ${distanceLabel(r.distanceFlown)} · max ${r.maxAltitude.round()} m', + style: _mono(s, 11, s.textSecondary), + ), + ]), + ), + const SizedBox(width: 8), + log.isUploading(r) + ? SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2, color: s.accent)) + : Text(label, style: _mono(s, 10, color)), + ]), + ), + ), + ); + } + + static (String, Color) _status(PVScheme s, FlightRecord r) { + if (r.simulated) return ('SIMULATOR', s.textTertiary); + if (r.uploaded) return ('LOGGED', s.successFg); + if (r.uploadError != null) return ('NOT UPLOADED', s.warningFg); + return ('LOCAL', s.textTertiary); + } +} + +TextStyle _mono(PVScheme s, double size, Color c) => TextStyle(fontFamily: PV.fontMono, fontSize: size, color: c); + +String _date(DateTime d) => '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'; +String _time(DateTime d) => '${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}'; + +/// The portrait library screens' back-arrow title row. +class PageHeader extends StatelessWidget { + const PageHeader({super.key, required this.title, this.trailing}); + + final String title; + final Widget? trailing; + + @override + Widget build(BuildContext context) { + final PVScheme s = PVScheme.of(context); + return Padding( + padding: const EdgeInsets.fromLTRB(12, 6, 20, 12), + child: Row(children: [ + IconButton(onPressed: () => Navigator.of(context).maybePop(), icon: PVIcon('chevronLeft', size: 24, color: s.textSecondary)), + Expanded( + child: Text(title, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontFamily: PV.fontSans, fontSize: 20, fontWeight: FontWeight.w700, letterSpacing: -0.4, color: s.textPrimary)), + ), + if (trailing != null) trailing!, + ]), + ); + } +} + +class _Card extends StatelessWidget { + const _Card({super.key, required this.icon, required this.title, required this.subtitle, this.onTap}); + + final String icon; + final String title; + final String subtitle; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final PVScheme s = PVScheme.of(context); + return Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 10), + child: GestureDetector( + onTap: onTap, + child: Container( + height: 62, + padding: const EdgeInsets.symmetric(horizontal: 14), + decoration: BoxDecoration(color: s.surface, borderRadius: BorderRadius.circular(14), border: Border.all(color: s.border), boxShadow: s.shadowXs), + child: Row(children: [ + Container( + width: 38, height: 38, + decoration: BoxDecoration(color: s.accentSoft, borderRadius: BorderRadius.circular(10)), + alignment: Alignment.center, + child: PVIcon(icon, size: 19, color: s.accentSoftFg), + ), + const SizedBox(width: 12), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ + Text(title, style: TextStyle(fontFamily: PV.fontSans, fontSize: 14.5, fontWeight: FontWeight.w600, color: s.textPrimary)), + Text(subtitle, overflow: TextOverflow.ellipsis, style: _mono(s, 11, s.textSecondary)), + ]), + ), + if (onTap != null) PVIcon('chevronRight', size: 18, color: s.textTertiary), + ]), + ), + ), + ); + } +} + +/// One recorded flight: its track on the map, its numbers, and the logbook upload. +class FlightRecordPage extends StatelessWidget { + const FlightRecordPage({super.key, required this.record, required this.log}); + + final FlightRecord record; + final FlightLog log; + + Future _delete(BuildContext context) async { + final bool? ok = await showDialog( + context: context, + builder: (BuildContext context) => AlertDialog( + title: const Text('Delete this flight record?'), + content: Text(record.uploaded + ? 'It is removed from this phone. The logbook entry on PilotVault stays.' + : 'It is removed from this phone and was never uploaded to the logbook.'), + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel')), + TextButton(onPressed: () => Navigator.of(context).pop(true), child: const Text('Delete')), + ], + ), + ); + if (ok != true || !context.mounted) return; + await log.delete(record); + if (context.mounted) Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + final PVScheme s = PVScheme.of(context); + final FlightRecord r = record; + return Scaffold( + backgroundColor: s.bgApp, + body: SafeArea( + child: ListenableBuilder( + listenable: log, + builder: (BuildContext context, _) => ListView(children: [ + PageHeader( + title: '${_date(r.start)} · ${_time(r.start)}', + trailing: IconButton(onPressed: () => _delete(context), icon: PVIcon('trash', size: 20, color: s.danger)), + ), + if (r.track.isNotEmpty) + Container( + height: 260, + margin: const EdgeInsets.fromLTRB(20, 0, 20, 14), + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration(borderRadius: BorderRadius.circular(14), border: Border.all(color: s.border)), + child: FlightTrackMap(record: r), + ), + _facts(s, r), + Padding(padding: const EdgeInsets.fromLTRB(20, 4, 20, 20), child: _upload(context, s, r)), + ]), + ), + ), + ); + } + + Widget _facts(PVScheme s, FlightRecord r) { + final List<(String, String)> facts = <(String, String)>[ + ('Aircraft', r.aircraftModel ?? '—'), + ('Duration', r.durationLabel), + ('Distance flown', distanceLabel(r.distanceFlown)), + ('Max height', '${r.maxAltitude.round()} m above take-off'), + ('Max distance', '${distanceLabel(r.maxDistance)} from home'), + ('Max speed', '${r.maxSpeed.toStringAsFixed(1)} m/s'), + if (r.batteryStart != null) ('Battery', '${r.batteryStart}% → ${r.batteryEnd ?? '—'}%'), + ('Flight controller SN', r.flightControllerSerial ?? 'not read'), + ]; + return Container( + margin: const EdgeInsets.symmetric(horizontal: 20), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + decoration: BoxDecoration(color: s.surface, borderRadius: BorderRadius.circular(14), border: Border.all(color: s.border)), + child: Column(children: [ + for (final (String k, String v) in facts) + Padding( + padding: const EdgeInsets.symmetric(vertical: 7), + child: Row(children: [ + Expanded(child: Text(k, style: TextStyle(fontFamily: PV.fontSans, fontSize: 13.5, color: s.textSecondary))), + Text(v, style: _mono(s, 12.5, s.textPrimary)), + ]), + ), + ]), + ); + } + + Widget _upload(BuildContext context, PVScheme s, FlightRecord r) { + if (r.simulated) { + return Text('Flown in the simulator: not a logbook flight.', style: _mono(s, 12, s.textTertiary)); + } + if (r.uploaded) { + return Row(children: [ + PVIcon('check', size: 16, color: s.successFg), + const SizedBox(width: 8), + Text('In the PilotVault logbook', style: TextStyle(fontFamily: PV.fontSans, fontSize: 13.5, color: s.successFg)), + ]); + } + return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + if (r.uploadError != null) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text(r.uploadError!, style: TextStyle(fontFamily: PV.fontSans, fontSize: 13, color: s.warningFg)), + ), + FilledButton( + key: const ValueKey('upload-flight'), + onPressed: log.isUploading(r) || !auth.isAuthed ? null : () => log.upload(r), + child: Text(log.isUploading(r) ? 'Uploading…' : (auth.isAuthed ? 'Upload to logbook' : 'Sign in to upload')), + ), + ]); + } +} + +/// A recorded flight's track, fitted to the map, with take-off and landing marks. +class FlightTrackMap extends StatelessWidget { + const FlightTrackMap({super.key, required this.record, this.extraMarkers = const []}); + + final FlightRecord record; + final List extraMarkers; + + @override + Widget build(BuildContext context) { + final List pts = [for (final TrackPoint p in record.track) p.latLng]; + final MapOptions options = pts.length >= 2 + ? MapOptions(initialCameraFit: CameraFit.bounds(bounds: LatLngBounds.fromPoints(pts), padding: const EdgeInsets.all(28), maxZoom: 18)) + : MapOptions(initialCenter: pts.first, initialZoom: 17); + return FlutterMap( + options: options, + children: [ + TileLayer(urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', userAgentPackageName: 'com.pilotvault.flyapp'), + 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))), + Marker(point: pts.last, width: 14, height: 14, child: _dot(const Color(0xFFE05252))), + ...extraMarkers, + ]), + ], + ); + } + + static Widget _dot(Color c) => Container( + decoration: BoxDecoration(color: c, shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 2)), ); } diff --git a/Fly App/lib/ui/fly/fly_screen.dart b/Fly App/lib/ui/fly/fly_screen.dart index 6b4d152..ad9ab61 100644 --- a/Fly App/lib/ui/fly/fly_screen.dart +++ b/Fly App/lib/ui/fly/fly_screen.dart @@ -44,6 +44,7 @@ import 'overlays/camera_touch_layer.dart'; import 'overlays/fly_zone_licences.dart'; import 'overlays/fly_zones.dart'; import 'overlays/gimbal_drag_layer.dart'; +import 'overlays/live_stream.dart'; import 'overlays/histogram_view.dart'; import 'overlays/safety_banners.dart'; import 'overlays/vision_radar.dart'; @@ -94,6 +95,10 @@ import 'top/status_bar.dart'; /// pads through the SafetyGate (simulator first); AirSense warnings above the /// error stack and the planes it hears on the map; DJI GEO fly zones on the /// map with their banner, and the radar ring on the attitude ball. +/// +/// Secondary screens (plan P9): the camera column's playback button opens the +/// album over the live view (videos play on the aircraft into the feed), and +/// a LIVE pill heads the left column while an RTMP stream runs. class FlyScreen extends StatefulWidget { const FlyScreen({super.key, required this.model, required this.dji, this.waypoints}); @@ -512,7 +517,7 @@ class _FlyScreenState extends State { dji: _dji, onError: _error, onCameraMenu: () => showCameraMenu(context, model: _m, dji: _dji, onError: _error), - onPlayback: () => _push(AlbumPage(model: _m, dji: _dji)), + onPlayback: () => Navigator.of(context).push(AlbumPage.route(model: _m, dji: _dji)), onCaptureModes: () => _push(CaptureModesPage(model: _m, dji: _dji)), ), ), @@ -656,6 +661,7 @@ class _FlyScreenState extends State { top: StatusBar.height + SmartBatteryBar.height + 8, bottom: _sticksOpen ? _stickPad + 18 : 104, child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + LiveBadge(live: _m.liveStream, dji: _dji, onError: _error), WaypointRunPanel(model: _m, editor: _waypoints, gate: _gate, flow: _flow, onError: _error), // Palm control (Spark) flies the aircraft from the pilot's hand: say so. ListenableBuilder( diff --git a/Fly App/lib/ui/fly/overlays/live_stream.dart b/Fly App/lib/ui/fly/overlays/live_stream.dart new file mode 100644 index 0000000..36f2fb8 --- /dev/null +++ b/Fly App/lib/ui/fly/overlays/live_stream.dart @@ -0,0 +1,169 @@ +import 'package:flutter/material.dart'; + +import '../../../dji_service.dart'; +import '../../../fly/fly_prefs.dart'; +import '../../../fly/state/live_stream_state.dart'; +import '../../../theme.dart'; +import '../hud_kit.dart'; +import '../panels/camera_menu/camera_rows.dart'; + +/// Starts or stops the RTMP stream, with [onError] for the refusal. +Future toggleLiveStream(DjiService dji, LiveStreamState live, {required void Function(String label, Object error) onError}) async { + try { + if (live.streaming) { + await dji.stopLiveStream(); + } else { + await dji.startLiveStream(FlyPrefs.liveStreamUrl.value, audio: FlyPrefs.liveStreamAudio.value); + } + } catch (e) { + onError(live.streaming ? 'Stop streaming' : 'Live stream', e); + } +} + +/// Settings → General → Live streaming (plan P9, GO 4's "Live streaming +/// platform"): the RTMP address (key masked), phone audio, and Start / Stop +/// with the stream's numbers. +class LiveStreamRows extends StatelessWidget { + const LiveStreamRows({super.key, required this.live, required this.dji, required this.connected, required this.onError}); + + final LiveStreamState live; + final DjiService dji; + final bool connected; + final void Function(String label, Object error) onError; + + Future _editUrl(BuildContext context) async { + final String? url = await showDialog(context: context, builder: (_) => _UrlDialog(initial: FlyPrefs.liveStreamUrl.value)); + if (url != null) await FlyPrefs.setLiveStreamUrl(url); + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: Listenable.merge([live, FlyPrefs.liveStreamUrl, FlyPrefs.liveStreamAudio]), + builder: (BuildContext context, _) { + final String url = FlyPrefs.liveStreamUrl.value; + final bool valid = validStreamUrl(url); + final String hint = live.streaming + ? 'Live ${live.elapsedLabel} · ${live.bitrateLabel} · ${live.fps.toStringAsFixed(0)} fps${live.congested ? ' · uplink behind' : ''}' + : live.error ?? (!connected ? 'Connect the aircraft to stream its camera' : (valid ? 'Sends the main camera\'s live view' : 'Set an rtmp:// address first')); + return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + ValueTile( + key: const ValueKey('row-liveStreamUrl'), + label: 'Stream address', + value: url.isEmpty ? 'Set' : maskStreamUrl(url), + hint: 'rtmp://server/app/stream-key from YouTube, Facebook or your own server', + onTap: live.streaming ? null : () => _editUrl(context), + ), + SwitchTile( + key: const ValueKey('row-liveStreamAudio'), + label: 'Send phone audio', + value: FlyPrefs.liveStreamAudio.value, + onChanged: FlyPrefs.setLiveStreamAudio, + ), + ButtonTile( + id: 'liveStreamToggle', + label: live.streaming ? 'Streaming' : 'Live stream', + hint: hint, + button: live.streaming ? 'Stop' : 'Start', + danger: live.streaming, + onTap: live.streaming || (connected && valid) ? () => toggleLiveStream(dji, live, onError: onError) : null, + ), + ]); + }, + ); + } +} + +/// Owns its controller, so the dialog's exit animation never sees it disposed. +class _UrlDialog extends StatefulWidget { + const _UrlDialog({required this.initial}); + + final String initial; + + @override + State<_UrlDialog> createState() => _UrlDialogState(); +} + +class _UrlDialogState extends State<_UrlDialog> { + late final TextEditingController _c = TextEditingController(text: widget.initial); + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final bool ok = _c.text.trim().isEmpty || validStreamUrl(_c.text); + return AlertDialog( + title: const Text('Stream address'), + content: TextField( + key: const ValueKey('liveStreamUrl-field'), + controller: _c, + autofocus: true, + keyboardType: TextInputType.url, + autocorrect: false, + onChanged: (_) => setState(() {}), + decoration: InputDecoration( + hintText: 'rtmp://a.rtmp.youtube.com/live2/xxxx', + errorText: ok ? null : 'Must be rtmp://host/app/key', + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel')), + TextButton(onPressed: ok ? () => Navigator.of(context).pop(_c.text.trim()) : null, child: const Text('Save')), + ], + ); + } +} + +/// The HUD's "● LIVE 1:05 · 2.4 Mbps" pill while streaming; amber when the +/// uplink falls behind. Tap to stop. +class LiveBadge extends StatelessWidget { + const LiveBadge({super.key, required this.live, required this.dji, required this.onError}); + + final LiveStreamState live; + final DjiService dji; + final void Function(String label, Object error) onError; + + Future _confirmStop(BuildContext context) async { + final bool? stop = await showDialog( + context: context, + builder: (BuildContext context) => AlertDialog( + title: const Text('Stop the live stream?'), + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Keep streaming')), + TextButton(key: const ValueKey('live-stop'), onPressed: () => Navigator.of(context).pop(true), child: const Text('Stop')), + ], + ), + ); + if (stop == true) await toggleLiveStream(dji, live, onError: onError); + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: live, + builder: (BuildContext context, _) { + if (!live.streaming) return const SizedBox.shrink(); + final Color dot = live.congested ? HudColors.caution : Glass.rec; + return Padding( + padding: const EdgeInsets.only(bottom: 6), + child: GlassPill( + key: const ValueKey('live-badge'), + color: Glass.pillStrong, + onTap: () => _confirmStop(context), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Container(width: 7, height: 7, decoration: BoxDecoration(color: dot, shape: BoxShape.circle)), + const SizedBox(width: 6), + Text('LIVE', style: HudText.label.copyWith(color: Glass.ink, fontWeight: FontWeight.w700)), + const SizedBox(width: 6), + Text('${live.elapsedLabel} · ${live.bitrateLabel}', style: HudText.label.copyWith(color: live.congested ? HudColors.caution : Glass.ink)), + ]), + ), + ); + }, + ); + } +} diff --git a/Fly App/lib/ui/fly/panels/settings/settings_sheet.dart b/Fly App/lib/ui/fly/panels/settings/settings_sheet.dart index 378dbfd..964ed36 100644 --- a/Fly App/lib/ui/fly/panels/settings/settings_sheet.dart +++ b/Fly App/lib/ui/fly/panels/settings/settings_sheet.dart @@ -20,6 +20,7 @@ import '../../hud_kit.dart'; import '../../overlays/airsense.dart'; import '../../overlays/fly_zone_licences.dart'; import '../../overlays/fly_zones.dart'; +import '../../overlays/live_stream.dart'; import '../camera_menu/camera_rows.dart'; import '../gimbal_sheet.dart'; import 'calibration/calibration_hub.dart'; @@ -328,6 +329,13 @@ class _FlySettingsSheetState extends State { gate: SafetyGate(model: _m, dji: widget.dji), onError: widget.onError, ), + SettingsTabs.liveStream => LiveStreamRows( + key: const ValueKey('row-liveStream'), + live: _m.liveStream, + dji: widget.dji, + connected: c.connected, + onError: widget.onError, + ), SettingsTabs.airSenseList => AirSenseList(key: const ValueKey('row-airSense'), airSense: _m.airSense, shrinkWrap: true), SettingsTabs.about => _about(), SettingsTabs.developer => _developer(), diff --git a/Fly App/lib/ui/profile_page.dart b/Fly App/lib/ui/profile_page.dart index 257095b..2d7bda8 100644 --- a/Fly App/lib/ui/profile_page.dart +++ b/Fly App/lib/ui/profile_page.dart @@ -5,8 +5,12 @@ import 'package:flutter/services.dart'; import '../dji_service.dart'; import '../flight_model.dart'; +import '../fly/find_my_drone.dart'; +import '../fly/flight_log.dart'; +import '../fly/flight_record.dart'; import '../pb_auth.dart'; import '../theme.dart'; +import 'find_my_drone_page.dart'; import 'flight_logs_page.dart'; import 'pv_icons.dart'; import 'routes_page.dart'; @@ -20,10 +24,14 @@ class ProfilePage extends StatefulWidget { required this.dji, required this.onAppSettings, required this.onSignIn, + required this.log, + required this.locator, }); final FlightModel model; final DjiService dji; + final FlightLog log; + final AircraftLocator locator; final VoidCallback onAppSettings; final VoidCallback onSignIn; @@ -83,7 +91,7 @@ class _ProfilePageState extends State { backgroundColor: s.bgApp, body: SafeArea( child: AnimatedBuilder( - animation: _m, + animation: Listenable.merge([_m, widget.log]), builder: (BuildContext context, _) => ListView( children: [ _headerBar(s), @@ -136,7 +144,15 @@ class _ProfilePageState extends State { } Widget _stats(PVScheme s) { - const List<(String, String)> stats = <(String, String)>[('Flights', '—'), ('Distance', '—'), ('Flight time', '—')]; + // Real flights only: simulator hours are not pilot experience. + final List real = [for (final FlightRecord r in widget.log.records) if (!r.simulated) r]; + final double metres = real.fold(0, (double t, FlightRecord r) => t + r.distanceFlown); + final int minutes = real.fold(0, (int t, FlightRecord r) => t + r.duration.inSeconds) ~/ 60; + final List<(String, String)> stats = <(String, String)>[ + ('Flights', '${real.length}'), + ('Distance', real.isEmpty ? '—' : distanceLabel(metres)), + ('Flight time', real.isEmpty ? '—' : (minutes >= 60 ? '${minutes ~/ 60} h ${minutes % 60} m' : '$minutes min')), + ]; return Container( margin: const EdgeInsets.fromLTRB(20, 0, 20, 18), decoration: BoxDecoration(color: s.surface, borderRadius: BorderRadius.circular(16), border: Border.all(color: s.border), boxShadow: s.shadowXs), @@ -184,10 +200,10 @@ class _ProfilePageState extends State { List _rows(PVScheme s) { final List<(String, String, VoidCallback)> rows = <(String, String, VoidCallback)>[ - ('gauge', 'Flight records', () => _push(const FlightLogsPage())), + ('gauge', 'Flight records', () => _push(FlightLogsPage(log: widget.log, locator: widget.locator, model: _m))), ('route', 'My routes', () => _push(const RoutesPage())), ('download', 'Downloads', () => _snack('Downloaded media is saved to the app files folder')), - ('shield', 'Find my drone', _findDrone), + ('pin', 'Find my drone', () => _push(FindMyDronePage(locator: widget.locator, log: widget.log, model: _m))), ('settings', 'App settings', widget.onAppSettings), ]; return [ @@ -217,12 +233,4 @@ class _ProfilePageState extends State { } void _push(Widget page) => Navigator.of(context).push(MaterialPageRoute(builder: (_) => page)); - - void _findDrone() { - if (_m.latitude != null && _m.longitude != null) { - _snack('Aircraft at ${_m.latitude!.toStringAsFixed(5)}, ${_m.longitude!.toStringAsFixed(5)}'); - } else { - _snack('No aircraft GPS fix available'); - } - } } diff --git a/Fly App/lib/ui/pv_icons.dart b/Fly App/lib/ui/pv_icons.dart index c0a7da0..97b33e2 100644 --- a/Fly App/lib/ui/pv_icons.dart +++ b/Fly App/lib/ui/pv_icons.dart @@ -86,6 +86,9 @@ class PvPaths { 'check': 'M20 6 9 17l-5-5', 'shield': 'M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6l8-3z', 'download': 'M12 3v12m0 0-4-4m4 4 4-4M4 21h16', + 'trash': 'M3 6h18M8 6V4h8v2M6 6l1 14h10l1-14M10 11v6M14 11v6', + 'checklist': 'M9 6h11M9 12h11M9 18h11M4 6l1 1 2-2M4 12l1 1 2-2M4 18l1 1 2-2', + 'live': 'M12 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2zM8.5 8.5a5 5 0 0 0 0 7M15.5 8.5a5 5 0 0 1 0 7M5.6 5.6a9 9 0 0 0 0 12.8M18.4 5.6a9 9 0 0 1 0 12.8', 'play2': 'M8 5v14l11-7z', }; } diff --git a/Fly App/test/fly/fake_dji.dart b/Fly App/test/fly/fake_dji.dart index de3748d..921b0da 100644 --- a/Fly App/test/fly/fake_dji.dart +++ b/Fly App/test/fly/fake_dji.dart @@ -169,6 +169,37 @@ class FakeDjiService extends DjiService { @override Future setHomeToCurrent() => _record('setHomeToCurrent'); + // ── Media + live streaming (plan P9) ─────────────────────────────────────── + /// What `deleteMedia` answers: how many files the camera refused. + int deleteRefused = 0; + + @override + Future refreshMediaList() => _record('refreshMediaList'); + @override + Future fetchThumbnail(int index) async => null; + @override + Future fetchMediaPreview(int index) async => null; + @override + Future downloadMedia(int index) async { + await _record('downloadMedia($index)'); + return '/files/$index'; + } + + @override + Future deleteMedia(List indices) async { + await _record('deleteMedia($indices)'); + return deleteRefused; + } + + @override + Future playMediaVideo(int index) => _record('playMediaVideo($index)'); + @override + Future stopMediaVideo() => _record('stopMediaVideo'); + @override + Future startLiveStream(String url, {bool audio = false}) => _record('startLiveStream($url, $audio)'); + @override + Future stopLiveStream() => _record('stopLiveStream'); + // ── KeyManager (plan P6) ─────────────────────────────────────────────────── /// Keys `isKeySupported` answers false for (by [DjiKey.key] name). final Set unsupportedKeys = {}; diff --git a/Fly App/test/fly/p9_secondary_test.dart b/Fly App/test/fly/p9_secondary_test.dart new file mode 100644 index 0000000..e278014 --- /dev/null +++ b/Fly App/test/fly/p9_secondary_test.dart @@ -0,0 +1,575 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pilotvault_fly/flight_model.dart'; +import 'package:pilotvault_fly/fly/find_my_drone.dart'; +import 'package:pilotvault_fly/fly/flight_log.dart'; +import 'package:pilotvault_fly/fly/flight_record.dart'; +import 'package:pilotvault_fly/fly/flight_record_sync.dart'; +import 'package:pilotvault_fly/fly/fly_prefs.dart'; +import 'package:pilotvault_fly/fly/preflight_checklist.dart'; +import 'package:pilotvault_fly/fly/state/live_stream_state.dart'; +import 'package:pilotvault_fly/fly/telemetry_reducer.dart'; +import 'package:pilotvault_fly/theme.dart'; +import 'package:pilotvault_fly/ui/academy_page.dart'; +import 'package:pilotvault_fly/ui/album_page.dart'; +import 'package:pilotvault_fly/ui/flight_logs_page.dart'; +import 'package:pilotvault_fly/ui/fly/overlays/live_stream.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'fake_dji.dart'; + +/// A controllable clock for the recorder, locator and checklist. +class _Clock { + DateTime now = DateTime(2026, 10, 20, 14, 0); + DateTime call() => now; + void advance(Duration d) => now = now.add(d); +} + +/// One flight-controller tick. +Map _tick({ + bool flying = true, + double lat = 55.6761, + double lon = 12.5683, + double alt = 30, + double dist = 0, + double speed = 0, + bool sim = false, +}) => + { + 'satelliteCount': 14, + 'isFlying': flying, + 'areMotorsOn': flying, + 'latitude': lat, + 'longitude': lon, + 'altitude': alt, + 'homeLatitude': 55.6761, + 'homeLongitude': 12.5683, + 'homeDistance': dist, + 'horizontalSpeed': speed, + 'simulatorActive': sim, + }; + +FlightModel _connected() => FlightModel() + ..connected = true + ..model = 'Mavic 2 Pro' + ..flightControllerSerial = 'FC123'; + +class _MemoryStore implements FlightRecordStore { + final Map saved = {}; + + @override + Future> loadAll() async => saved.values.toList(); + @override + Future save(FlightRecord r) async => saved[r.id] = r; + @override + Future delete(String id) async => saved.remove(id); +} + +class _FakeLogbook implements LogbookRemote { + bool signedIn = true; + Object? failWith; + final List> flights = >[]; + final List drones = []; + + @override + bool get available => signedIn; + + @override + Future ensureDrone({required String flightControllerSerial, String? model, String? firmware}) async { + if (failWith != null) throw failWith!; + drones.add(flightControllerSerial); + return 'drone-1'; + } + + @override + Future createFlight(Map entry) async { + flights.add(entry); + return 'flight-${flights.length}'; + } +} + +FlightRecord _record({String id = 'fr-1', bool sim = false, String? serial = 'FC123', int points = 3}) => FlightRecord( + id: id, + start: DateTime(2026, 10, 20, 14, 5), + end: DateTime(2026, 10, 20, 14, 17, 30), + aircraftModel: 'Mavic 2 Pro', + flightControllerSerial: serial, + homeLatitude: 55.6761, + homeLongitude: 12.5683, + track: [for (int i = 0; i < points; i++) TrackPoint(55.6761 + i * 0.001, 12.5683, 20.0 + i, i)], + maxAltitude: 42.4, + maxDistance: 310, + maxSpeed: 9.5, + distanceFlown: 1234, + batteryStart: 96, + batteryEnd: 41, + simulated: sim, + ); + +void main() { + group('FlightRecorder', () { + late _Clock clock; + late FlightModel m; + late List done; + late FlightRecorder rec; + + setUp(() { + clock = _Clock(); + m = _connected(); + done = []; + rec = FlightRecorder(model: m, onFinished: done.add, clock: clock.call); + }); + + tearDown(() => rec.dispose()); + + void tick(Map t, {Duration after = const Duration(milliseconds: 100)}) { + clock.advance(after); + m.aircraft.apply(t); + } + + test('records take-off to landing with a 1 Hz track and the maxima', () { + tick(_tick(flying: false)); + expect(rec.recording, isFalse); + m.battery.apply({'percent': 95}); + tick(_tick(alt: 1)); + expect(rec.recording, isTrue); + // 20 s of flight at 10 Hz, climbing and moving north. + for (int i = 1; i <= 200; i++) { + tick(_tick(lat: 55.6761 + i * 0.00001, alt: 1 + i * 0.2, dist: i.toDouble(), speed: i == 100 ? 12 : 5)); + } + m.battery.apply({'percent': 80}); + // Landed where it last flew: the flight ends once the grace has passed. + const double end = 55.6761 + 200 * 0.00001; + tick(_tick(flying: false, lat: end, alt: 0)); + expect(done, isEmpty); + tick(_tick(flying: false, lat: end, alt: 0), after: const Duration(seconds: 6)); + expect(rec.recording, isFalse); + final FlightRecord r = done.single; + expect(r.track.length, inInclusiveRange(20, 22)); // one a second + expect(r.maxAltitude, closeTo(41, 0.01)); + expect(r.maxDistance, 200); + expect(r.maxSpeed, 12); + expect(r.distanceFlown, closeTo(222, 3)); // 200 × 0.00001° ≈ 222 m + expect(r.batteryStart, 95); + expect(r.batteryEnd, 80); + expect(r.flightControllerSerial, 'FC123'); + expect(r.duration.inSeconds, inInclusiveRange(20, 21)); // ends when it landed, not after the grace + expect(r.simulated, isFalse); + }); + + test('drops hops shorter than the minimum', () { + tick(_tick()); + tick(_tick(), after: const Duration(seconds: 2)); + tick(_tick(flying: false)); + tick(_tick(flying: false), after: const Duration(seconds: 6)); + expect(done, isEmpty); + expect(rec.recording, isFalse); + }); + + test('a touch-and-go within the grace stays one flight', () { + tick(_tick()); + tick(_tick(), after: const Duration(seconds: 10)); + tick(_tick(flying: false)); + tick(_tick(flying: false), after: const Duration(seconds: 2)); + tick(_tick(), after: const Duration(seconds: 1)); + tick(_tick(), after: const Duration(seconds: 10)); + tick(_tick(flying: false)); + tick(_tick(flying: false), after: const Duration(seconds: 6)); + expect(done, hasLength(1)); + }); + + test('a disconnect mid-air ends the flight where it was last seen', () { + tick(_tick()); + tick(_tick(lat: 55.68), after: const Duration(seconds: 30)); + final DateTime lastSeen = clock.now; + clock.advance(const Duration(seconds: 20)); + m.connected = false; + m.clearTelemetry(); + m.bump(); + expect(done.single.end, lastSeen); + expect(done.single.lastPoint!.latitude, 55.68); + }); + + test('marks simulator flights', () { + tick(_tick(sim: true)); + tick(_tick(sim: true), after: const Duration(seconds: 30)); + tick(_tick(flying: false, sim: true)); + tick(_tick(flying: false, sim: true), after: const Duration(seconds: 6)); + expect(done.single.simulated, isTrue); + }); + + test('ignores (0, 0) fixes in the track', () { + tick(_tick(lat: 0, lon: 0)); + for (int i = 0; i < 10; i++) { + tick(_tick(lat: 0, lon: 0), after: const Duration(seconds: 1)); + } + tick(_tick(flying: false, lat: 0, lon: 0)); + tick(_tick(flying: false, lat: 0, lon: 0), after: const Duration(seconds: 6)); + expect(done.single.track, isEmpty); + }); + }); + + group('FlightRecord', () { + test('round-trips through JSON', () { + final FlightRecord r = _record()..logbookId = 'abc'; + final FlightRecord back = FlightRecord.fromJson(Map.from(r.toJson())); + expect(back.toJson(), r.toJson()); + expect(back.track[1].latitude, closeTo(55.6771, 1e-9)); + expect(back.uploaded, isTrue); + }); + + test('labels its duration', () { + expect(_record().durationLabel, '12 min 30 s'); + }); + }); + + group('logbook upload', () { + test('the entry carries the § 5 fields, the track and the numbers', () { + final Map e = logbookEntry(_record(), droneId: 'd1'); + expect(e['operationDate'], '2026-10-20'); + expect(e['startTime'], '14:05'); + expect(e['endTime'], '14:17'); + expect(e['drone'], 'd1'); + expect(e['maxAltitudeAgl'], 42.4); + expect(e['loggingPath'], 'automatic'); + expect(e['areaRoute'], 'Take-off 55.67610° N, 12.56830° E'); + final Map route = e['routeGeojson']! as Map; + expect(route['type'], 'LineString'); + expect((route['coordinates']! as List).first, [12.5683, 55.6761, 20.0]); // lon, lat, alt + expect(e['notes'], contains('1.23 km flown')); + expect(e['notes'], contains('battery 96% → 41%')); + }); + + test('long tracks are thinned evenly, keeping both ends', () { + final List track = [for (int i = 0; i < 2000; i++) TrackPoint(55 + i * 1e-5, 12, 10, i)]; + final List coords = routeGeoJson(track, maxPoints: 100)!['coordinates']! as List; + expect(coords, hasLength(100)); + expect((coords.first! as List)[1], 55); + expect((coords.last! as List)[1], closeTo(55 + 1999e-5, 1e-6)); + expect(routeGeoJson(track.take(1).toList()), isNull); + }); + + test('uploads through the fleet drone', () async { + final _FakeLogbook remote = _FakeLogbook(); + final FlightRecord r = await uploadFlightRecord(_record(), remote); + expect(r.logbookId, 'flight-1'); + expect(r.uploadError, isNull); + expect(remote.drones, ['FC123']); + // A second upload is a no-op. + await uploadFlightRecord(r, remote); + expect(remote.flights, hasLength(1)); + }); + + test('refuses simulator flights, missing serials and signed-out uploads', () async { + final _FakeLogbook remote = _FakeLogbook(); + expect((await uploadFlightRecord(_record(sim: true), remote)).uploadError, contains('Simulator')); + expect((await uploadFlightRecord(_record(serial: null), remote)).uploadError, contains('serial')); + remote.signedIn = false; + expect((await uploadFlightRecord(_record(), remote)).uploadError, contains('Sign in')); + expect(remote.flights, isEmpty); + }); + + test('keeps the server\'s reason on failure', () async { + final _FakeLogbook remote = _FakeLogbook()..failWith = const LogbookException('this drone is registered to another organisation'); + final FlightRecord r = await uploadFlightRecord(_record(), remote); + expect(r.uploaded, isFalse); + expect(r.uploadError, 'this drone is registered to another organisation'); + remote.failWith = const SocketException('down'); + expect((await uploadFlightRecord(r, remote)).uploadError, 'The API Server could not be reached'); + }); + }); + + group('FlightLog', () { + setUp(() => FlyPrefs.autoUploadFlights.value = true); + + test('saves each finished flight and uploads it when signed in', () async { + final _MemoryStore store = _MemoryStore(); + final _FakeLogbook remote = _FakeLogbook(); + final FlightLog log = FlightLog(store: store, remote: remote); + await log.load(); + await log.add(_record(id: 'fr-1')); + await log.add(_record(id: 'fr-2', sim: true)); + expect(store.saved.keys, containsAll(['fr-1', 'fr-2'])); + expect(store.saved['fr-1']!.logbookId, 'flight-1'); + expect(store.saved['fr-2']!.uploaded, isFalse); // simulator: never uploaded + expect(log.pending, isEmpty); + }); + + test('keeps flights pending while signed out, and sends them later', () async { + final _FakeLogbook remote = _FakeLogbook()..signedIn = false; + final FlightLog log = FlightLog(store: _MemoryStore(), remote: remote); + await log.add(_record(id: 'fr-1')); + await log.add(_record(id: 'fr-2')); + expect(log.pending, hasLength(2)); + remote.signedIn = true; + expect(await log.uploadPending(), 2); + expect(log.pending, isEmpty); + }); + + test('auto-upload off leaves new flights local', () async { + FlyPrefs.autoUploadFlights.value = false; + final _FakeLogbook remote = _FakeLogbook(); + final FlightLog log = FlightLog(store: _MemoryStore(), remote: remote); + await log.add(_record()); + expect(remote.flights, isEmpty); + expect(log.pending, hasLength(1)); + }); + + test('lists newest first', () async { + final _MemoryStore store = _MemoryStore() + ..saved['a'] = FlightRecord(id: 'a', start: DateTime(2026, 1, 1), end: DateTime(2026, 1, 1)) + ..saved['b'] = FlightRecord(id: 'b', start: DateTime(2026, 3, 1), end: DateTime(2026, 3, 1)); + final FlightLog log = FlightLog(store: store, remote: _FakeLogbook()); + await log.load(); + expect(log.records.map((FlightRecord r) => r.id), ['b', 'a']); + expect(log.latest!.id, 'b'); + }); + + test('the file store round-trips and skips broken files', () async { + final Directory tmp = await Directory.systemTemp.createTemp('flight_records'); + addTearDown(() => tmp.delete(recursive: true)); + final FileFlightRecordStore store = FileFlightRecordStore(root: () async => tmp); + await store.save(_record(id: 'fr-1')); + await File('${tmp.path}${Platform.pathSeparator}flight_records${Platform.pathSeparator}junk.json').writeAsString('{not json'); + final List all = await store.loadAll(); + expect(all.single.id, 'fr-1'); + expect(all.single.track, hasLength(3)); + await store.delete('fr-1'); + expect(await store.loadAll(), isEmpty); + }); + }); + + group('Find My Drone', () { + setUp(() => SharedPreferences.setMockInitialValues({})); + + test('keeps the last fix and saves it for the next launch', () async { + final _Clock clock = _Clock(); + final FlightModel m = _connected(); + final AircraftLocator loc = AircraftLocator(model: m, clock: clock.call); + m.battery.apply({'percent': 37}); + m.aircraft.apply(_tick(lat: 55.7, lon: 12.6, alt: 48)); + expect(loc.last!.latitude, 55.7); + expect(loc.last!.inFlight, isTrue); + expect(loc.last!.batteryPercent, 37); + // Lost link: the position stays, and is on disk. + clock.advance(const Duration(seconds: 1)); + m.aircraft.apply(_tick(lat: 55.701, lon: 12.6)); + m.connected = false; + m.clearTelemetry(); + m.bump(); + expect(loc.last!.latitude, 55.701); + await Future.delayed(Duration.zero); + loc.dispose(); + + final AircraftLocator next = AircraftLocator(model: FlightModel()); + await next.load(); + expect(next.last!.latitude, 55.701); + expect(next.last!.altitude, 30); + next.dispose(); + }); + + test('does not record without a fix or a connection', () { + final FlightModel m = FlightModel(); + final AircraftLocator loc = AircraftLocator(model: m); + m.aircraft.apply(_tick()); + expect(loc.last, isNull); + m.connected = true; + m.aircraft.apply(_tick(lat: 0, lon: 0)); + expect(loc.last, isNull); + loc.dispose(); + }); + + test('distance, direction and age read naturally', () { + final LastKnownPosition p = LastKnownPosition(latitude: 55.6861, longitude: 12.5683, at: DateTime(2026, 10, 20, 14)); + expect(p.distanceFrom(55.6761, 12.5683), closeTo(1112, 2)); + expect(compassPoint(p.bearingFrom(55.6761, 12.5683)), 'N'); + expect(compassPoint(134), 'SE'); + expect(compassPoint(359), 'N'); + expect(distanceLabel(1112), '1.1 km'); + expect(distanceLabel(84.4), '84 m'); + expect(agoLabel(DateTime(2026, 10, 20, 14), DateTime(2026, 10, 20, 14, 12)), '12 min ago'); + expect(agoLabel(DateTime(2026, 10, 18), DateTime(2026, 10, 20, 1)), '2 days ago'); + }); + }); + + group('PreflightChecklist', () { + setUp(() => SharedPreferences.setMockInitialValues({})); + + test('ticks persist for the session and go stale after it', () async { + final _Clock clock = _Clock(); + final PreflightChecklist c = PreflightChecklist(clock: clock.call); + c.toggle(c.items[0]); + c.toggle(c.items[1]); + c.toggle(c.items[1]); + expect(c.done, 1); + await Future.delayed(Duration.zero); + + final PreflightChecklist again = PreflightChecklist(clock: clock.call); + await again.load(); + expect(again.isTicked(again.items[0]), isTrue); + + clock.advance(const Duration(hours: 7)); + final PreflightChecklist tomorrow = PreflightChecklist(clock: clock.call); + await tomorrow.load(); + expect(tomorrow.done, 0); + }); + + test('complete once every item is ticked', () { + final PreflightChecklist c = PreflightChecklist(); + for (final PreflightItem i in c.items) { + c.toggle(i); + } + expect(c.complete, isTrue); + c.reset(); + expect(c.done, 0); + }); + }); + + group('live streaming', () { + test('reads the bridge state', () { + final FlightModel m = FlightModel(); + TelemetryReducer(m).apply({ + 'type': 'liveStream', + 'streaming': true, + 'status': 'STREAMING', + 'videoKbps': 2200, + 'audioKbps': 128, + 'fps': 29.97, + 'seconds': 3725, + 'cacheSize': 90, + }); + final LiveStreamState l = m.liveStream; + expect(l.streaming, isTrue); + expect(l.bitrateLabel, '2.3 Mbps'); + expect(l.elapsedLabel, '1:02:05'); + expect(l.congested, isTrue); + }); + + test('masks the stream key and validates addresses', () { + expect(maskStreamUrl('rtmp://a.rtmp.youtube.com/live2/abcd-efgh-x9'), 'rtmp://a.rtmp.youtube.com/live2/•••x9'); + expect(maskStreamUrl('rtmp://host'), 'rtmp://host'); + expect(validStreamUrl('rtmp://a.rtmp.youtube.com/live2/key'), isTrue); + expect(validStreamUrl('rtmps://live-api-s.facebook.com:443/rtmp/key'), isTrue); + expect(validStreamUrl('http://example.com/live'), isFalse); + expect(validStreamUrl('rtmp://host'), isFalse); + expect(validStreamUrl(''), isFalse); + }); + + testWidgets('Start needs a connection and an address; the badge stops it', (WidgetTester tester) async { + final FakeDjiService dji = FakeDjiService(); + final LiveStreamState live = LiveStreamState(); + FlyPrefs.liveStreamUrl.value = ''; + Widget host(bool connected) => MaterialApp( + theme: PV.theme(), + home: Scaffold( + body: Column(children: [ + LiveStreamRows(live: live, dji: dji, connected: connected, onError: (_, __) {}), + LiveBadge(live: live, dji: dji, onError: (_, __) {}), + ]), + ), + ); + await tester.pumpWidget(host(true)); + await tester.tap(find.byKey(const ValueKey('action-liveStreamToggle'))); + expect(dji.calls, isEmpty); // no address yet + FlyPrefs.liveStreamUrl.value = 'rtmp://host/app/key'; + await tester.pump(); + await tester.tap(find.byKey(const ValueKey('action-liveStreamToggle'))); + expect(dji.calls, ['startLiveStream(rtmp://host/app/key, false)']); + expect(find.byKey(const ValueKey('live-badge')), findsNothing); + + live.apply({'streaming': true, 'videoKbps': 900, 'seconds': 65}); + await tester.pump(); + expect(find.text('1:05 · 900 kbps'), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('live-badge'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.byKey(const ValueKey('live-stop'))); + await tester.pumpAndSettle(); + expect(dji.calls.last, 'stopLiveStream'); + }); + }); + + group('playback album', () { + MediaItem item(int i, {String type = 'JPEG', bool video = false}) => + MediaItem.fromMap({'index': i, 'key': 'k$i', 'fileName': 'DJI_000$i', 'type': video ? 'video' : 'photo', 'mediaType': type}); + + test('filters by kind', () { + final MediaItem photo = item(0), clip = item(1, type: 'MP4', video: true), pano = item(2, type: 'PANORAMA'); + expect(AlbumFilter.photos.accepts(photo), isTrue); + expect(AlbumFilter.photos.accepts(pano), isFalse); + expect(AlbumFilter.pano.accepts(pano), isTrue); + expect(AlbumFilter.videos.accepts(clip), isTrue); + expect(MediaItem.fromMap({'index': 0, 'sizeBytes': 13002342}).sizeLabel, '12.4 MB'); + }); + + testWidgets('long press selects, delete asks first, leaving restores the camera', (WidgetTester tester) async { + final FakeDjiService dji = FakeDjiService(); + final FlightModel m = FlightModel() + ..connected = true + ..captureMode = CaptureMode.video + ..media = [item(0), item(1), item(2, type: 'MP4', video: true)]; + await tester.pumpWidget(MaterialApp(theme: PV.theme(), home: AlbumPage(model: m, dji: dji))); + m.mediaLoading = false; + m.bump(); + await tester.pump(); + expect(dji.calls, ['refreshMediaList']); + + await tester.longPress(find.byKey(const ValueKey('media-k0'))); + await tester.pump(); + await tester.tap(find.byKey(const ValueKey('media-k2'))); + await tester.pump(); + expect(find.text('2 selected'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('album-delete'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('album-delete-confirm'))); + await tester.pumpAndSettle(); + expect(dji.calls, contains('deleteMedia([0, 2])')); + expect(find.text('2 selected'), findsNothing); + + await tester.pumpWidget(const SizedBox()); + expect(dji.calls.last, 'setCameraMode(video)'); + }); + }); + + testWidgets('the checklist page ticks items', (WidgetTester tester) async { + SharedPreferences.setMockInitialValues({}); + final PreflightChecklist c = PreflightChecklist(); + await tester.pumpWidget(MaterialApp(theme: PV.theme(), home: PreflightChecklistPage(model: FlightModel(), checklist: c))); + expect(find.text('Connect the aircraft to see its own checks here.'), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('check-site'))); + await tester.pump(); + expect(c.done, 1); + expect(find.text('1 of ${c.items.length} checks done'), findsOneWidget); + }); + + testWidgets('flight logs and the Academy fit a phone', (WidgetTester tester) async { + SharedPreferences.setMockInitialValues({}); + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final _FakeLogbook remote = _FakeLogbook()..signedIn = false; + final FlightLog log = FlightLog(store: _MemoryStore(), remote: remote); + await log.load(); + await log.add(_record(id: 'fr-1')); + await log.add(_record(id: 'fr-2', sim: true)); + final FlightModel m = FlightModel(); + final AircraftLocator loc = AircraftLocator(model: m); + await tester.pumpWidget(MaterialApp(theme: PV.theme(), home: FlightLogsPage(log: log, locator: loc, model: m))); + await tester.pump(); + expect(find.text('2 flights'), findsOneWidget); + expect(find.text('1 flight not in the logbook'), findsOneWidget); + expect(find.text('SIMULATOR'), findsOneWidget); + expect(find.text('LOCAL'), findsOneWidget); // signed out: never tried + + await tester.pumpWidget(MaterialApp(theme: PV.theme(), home: AcademyPage(model: m, checklist: PreflightChecklist()))); + await tester.pump(); + expect(find.text('Pre-flight checklist'), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('article-rth'))); + await tester.pumpAndSettle(); + expect(find.text('Return-to-home height'), findsOneWidget); + loc.dispose(); + }); +}