diff --git a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/CameraBridge.kt b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/CameraBridge.kt index 14c157d..e6b5fda 100644 --- a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/CameraBridge.kt +++ b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/CameraBridge.kt @@ -1,5 +1,7 @@ package com.pilotvault.flyapp +import android.graphics.Point +import android.graphics.PointF import dji.common.camera.ExposureSettings import dji.common.camera.ResolutionAndFrameRate import dji.common.camera.SSDState @@ -7,7 +9,10 @@ import dji.common.camera.SettingsDefinitions import dji.common.camera.StorageState import dji.common.camera.SystemState import dji.common.camera.WhiteBalance +import dji.common.error.DJIError import dji.keysdk.CameraKey +import dji.keysdk.KeyManager +import dji.keysdk.callback.SetCallback import dji.sdk.base.BaseProduct import dji.sdk.camera.Camera import dji.sdk.products.Aircraft @@ -15,18 +20,22 @@ import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel /** - * Camera control: mode, photo/record, shoot-photo modes, and Pro exposure - * (ISO / shutter / aperture / EV / WB). Emits `camera` (system state) and - * `exposure` events. String↔enum mapping mirrors the v2 kit's scrubber labels; - * unsupported values fail cleanly rather than crashing. + * Camera control: mode, photo/record, shoot-photo modes, exposure, the camera + * menu's settings, tap-to-focus / spot metering, zoom and the histogram. Emits + * `camera` (system state), `exposure` and `histogram` events. String↔enum + * mapping mirrors the v2 kit's scrubber labels; unsupported values fail cleanly + * rather than crashing. * - * Also feeds two slow groups of the [TelemetryHub]: + * Also feeds four slow groups of the [TelemetryHub]: * ``` * storage: {location, inserted, initializing, formatting, formatted, full, error, * readOnly, invalidFormat, verified, totalMB, remainingMB, * availableCaptureCount, availableRecordingSeconds} - * cameraSettings: {aeLock, focusMode, photoAspectRatio, photoFileFormat, - * videoResolution, videoFrameRate, videoFileFormat, colorProfile} + * cameraSettings: {, isoSetting, videoResolution, videoFrameRate, + * whiteBalancePreset, whiteBalanceKelvin, intervalCount, intervalSeconds, + * styleSharpness, styleContrast, styleSaturation, spotCol, spotRow, + * focusX, focusY, zoomFocalLength, zoomMin, zoomMax, zoomStep, + * zoomOpticalMax, digitalZoomFactor} * ssd: {connected, state, capacity, remainingMB, availableRecordingSeconds} * thermal: {spotTemperature} * ``` @@ -34,14 +43,53 @@ import io.flutter.plugin.common.MethodChannel * on thermal cameras (XT / XT2 / Mavic 2 Enterprise Dual); the HUD's SSD and * thermal readouts (plan P2) are hidden on everything else. * The settings are watched keys, so a change made from the RC's dial or the - * settings sheet shows up without a re-read. + * camera menu shows up without a re-read — the menu (plan P4) shows what the + * camera reports back, never what it asked for. */ class CameraBridge(private val ctx: BridgeCtx) : SubBridge { + /** + * One simple camera setting: its id in the `cameraSettings` group (and in + * `setCameraValue`), its CameraKey, and its value type (an enum class, or + * Boolean). The table drives both the watch and the setter, so a camera + * menu row is one line here plus one row spec in Dart. + */ + private class Setting(val key: String, val type: Class<*>) + + private val settings: Map = linkedMapOf( + "aeLock" to Setting(CameraKey.AE_LOCK, java.lang.Boolean::class.java), + "focusMode" to Setting(CameraKey.FOCUS_MODE, SettingsDefinitions.FocusMode::class.java), + "exposureMode" to Setting(CameraKey.EXPOSURE_MODE, SettingsDefinitions.ExposureMode::class.java), + "meteringMode" to Setting(CameraKey.METERING_MODE, SettingsDefinitions.MeteringMode::class.java), + "shootPhotoMode" to Setting(CameraKey.SHOOT_PHOTO_MODE, SettingsDefinitions.ShootPhotoMode::class.java), + "aebCount" to Setting(CameraKey.PHOTO_AEB_COUNT, SettingsDefinitions.PhotoAEBCount::class.java), + "burstCount" to Setting(CameraKey.PHOTO_BURST_COUNT, SettingsDefinitions.PhotoBurstCount::class.java), + "panoramaMode" to Setting(CameraKey.PHOTO_PANORAMA_MODE, SettingsDefinitions.PhotoPanoramaMode::class.java), + "photoAspectRatio" to Setting(CameraKey.PHOTO_ASPECT_RATIO, SettingsDefinitions.PhotoAspectRatio::class.java), + "photoFileFormat" to Setting(CameraKey.PHOTO_FILE_FORMAT, SettingsDefinitions.PhotoFileFormat::class.java), + "videoFileFormat" to Setting(CameraKey.VIDEO_FILE_FORMAT, SettingsDefinitions.VideoFileFormat::class.java), + "videoStandard" to Setting(CameraKey.VIDEO_STANDARD, SettingsDefinitions.VideoStandard::class.java), + "colorProfile" to Setting(CameraKey.CAMERA_COLOR, SettingsDefinitions.CameraColor::class.java), + "antiFlicker" to Setting(CameraKey.ANTI_FLICKER_FREQUENCY, SettingsDefinitions.AntiFlickerFrequency::class.java), + "fileIndexMode" to Setting(CameraKey.FILE_INDEX_MODE, SettingsDefinitions.FileIndexMode::class.java), + "videoCaption" to Setting(CameraKey.VIDEO_CAPTION_ENABLED, java.lang.Boolean::class.java), + "autoLockGimbal" to Setting(CameraKey.AUTO_LOCK_GIMBAL_ENABLED, java.lang.Boolean::class.java), + "histogramEnabled" to Setting(CameraKey.HISTOGRAM_ENABLED, java.lang.Boolean::class.java), + ) + private val keys = KeyWatch(ctx) + /** Histogram frames arrive at the video rate; the HUD needs a few per second. */ + private var lastHistogramAt = 0L + private fun cam(): Camera? = ctx.aircraft()?.camera + private fun index(camera: Camera): Int = try { + camera.index + } catch (_: Throwable) { + 0 + } + override fun handle(call: MethodCall, result: MethodChannel.Result): Boolean { val camera = cam() when (call.method) { @@ -64,6 +112,8 @@ class CameraBridge(private val ctx: BridgeCtx) : SubBridge { if (mode == null) ctx.fail(result, "Unsupported photo mode") else it.setShootPhotoMode(mode, ctx.completion(result)) } + // The classic flight screen's Auto / Pro switch. The HUD sets the four + // GO 4 modes through `setCameraValue` / `exposureMode` instead. "setExposureProgram" -> guard(camera, result) { val mode = if (call.argument("program") == "pro") SettingsDefinitions.ExposureMode.MANUAL @@ -91,6 +141,71 @@ class CameraBridge(private val ctx: BridgeCtx) : SubBridge { if (wb == null) ctx.fail(result, "Unsupported white balance") else it.setWhiteBalance(wb, ctx.completion(result)) } + "setCameraValue" -> guard(camera, result) { setValue(it, call, result) } + "setVideoResolutionAndFrameRate" -> guard(camera, result) { + val res = enumOrNull(call.argument("resolution")) + val fps = enumOrNull(call.argument("frameRate")) + if (res == null || fps == null) ctx.fail(result, "Unsupported video size") + else it.setVideoResolutionAndFrameRate(ResolutionAndFrameRate(res, fps), ctx.completion(result)) + } + "setPhotoTimeInterval" -> guard(camera, result) { + val count = call.argument("count") ?: 255 + val seconds = call.argument("seconds") ?: 5 + it.setPhotoTimeIntervalSettings( + SettingsDefinitions.PhotoTimeIntervalSettings(count, seconds), + ctx.completion(result), + ) + } + "setPictureStyle" -> guard(camera, result) { + val style = SettingsDefinitions.PictureStylePreset.Builder() + .sharpness(call.argument("sharpness") ?: 0) + .contrast(call.argument("contrast") ?: 0) + .saturation(call.argument("saturation") ?: 0) + .build() + setKey(CameraKey.create(CameraKey.PICTURE_STYLE_PRESET, index(it)), style, result) + } + // Tap-to-focus: x/y are 0..1 across the live view. + "setFocusTarget" -> guard(camera, result) { + val p = PointF(unit(call.argument("x")), unit(call.argument("y"))) + it.setFocusTarget(p, ctx.completion(result)) + } + // Spot metering on the camera's own grid (12 × 8 on most cameras): switch + // to SPOT, then move the spot to the cell under x/y. + "setSpotMeteringTarget" -> guard(camera, result) { c -> + val (cols, rows) = meteringGrid(c) + val col = (unit(call.argument("x")) * cols).toInt().coerceIn(0, cols - 1) + val row = (unit(call.argument("y")) * rows).toInt().coerceIn(0, rows - 1) + c.setMeteringMode(SettingsDefinitions.MeteringMode.SPOT) { error -> + if (error != null) ctx.mainHandler.post { result.error("DJI_ERROR", error.description, null) } + else c.setSpotMeteringTarget(Point(col, row), ctx.completion(result)) + } + } + "startContinuousZoom" -> guard(camera, result) { + if (safe { it.isOpticalZoomSupported } != true) { + ctx.fail(result, "This camera zooms in steps only") + } else { + val dir = if (call.argument("direction") == "out") + SettingsDefinitions.ZoomDirection.ZOOM_OUT + else SettingsDefinitions.ZoomDirection.ZOOM_IN + it.startContinuousOpticalZoom(dir, SettingsDefinitions.ZoomSpeed.NORMAL, ctx.completion(result)) + } + } + "stopContinuousZoom" -> guard(camera, result) { it.stopContinuousOpticalZoom(ctx.completion(result)) } + // A focal length in the camera's own units (mm × 10 on the Mavic 2 Zoom). + "setZoomFocalLength" -> guard(camera, result) { + val mm = call.argument("value") + when { + mm == null -> ctx.fail(result, "No focal length") + safe { it.isHybridZoomSupported } == true -> it.setHybridZoomFocalLength(mm, ctx.completion(result)) + safe { it.isOpticalZoomSupported } == true -> it.setOpticalZoomFocalLength(mm, ctx.completion(result)) + else -> ctx.fail(result, "No optical zoom") + } + } + "setDigitalZoomFactor" -> guard(camera, result) { + val f = call.argument("value")?.toFloat() ?: 1f + it.setDigitalZoomFactor(f, ctx.completion(result)) + } + "restoreCameraDefaults" -> guard(camera, result) { it.restoreFactorySettings(ctx.completion(result)) } // Erases the card (Aircraft Status sheet, plan P3). `location` picks the SD // card or internal storage; the UI slide-confirms and refuses while recording. "formatStorage" -> guard(camera, result) { @@ -100,10 +215,8 @@ class CameraBridge(private val ctx: BridgeCtx) : SubBridge { } it.formatStorage(location, ctx.completion(result)) } - "setHistogramEnabled" -> { - // Histogram data streaming is model-dependent; accept the toggle so - // the UI stays responsive. Real histogram bins are a later add. - result.success(null) + "setHistogramEnabled" -> guard(camera, result) { + it.setHistogramEnabled(call.argument("value") == true, ctx.completion(result)) } else -> return false } @@ -114,6 +227,54 @@ class CameraBridge(private val ctx: BridgeCtx) : SubBridge { if (camera == null) ctx.fail(result, "No camera connected") else block(camera) } + /** `setCameraValue {id, value}`: one row of [settings], by enum name or bool. */ + private fun setValue(camera: Camera, call: MethodCall, result: MethodChannel.Result) { + val id = call.argument("id") + val s = settings[id] + if (s == null) { + ctx.fail(result, "Unknown camera setting $id") + return + } + val raw = call.argument("value") + val value: Any? = if (s.type == java.lang.Boolean::class.java) { + raw as? Boolean + } else { + s.type.enumConstants?.firstOrNull { (it as Enum<*>).name == raw?.toString() } + } + if (value == null) { + ctx.fail(result, "Unsupported value $raw for $id") + return + } + setKey(CameraKey.create(s.key, index(camera)), value, result) + } + + private fun setKey(key: CameraKey, value: Any, result: MethodChannel.Result) { + val km = KeyManager.getInstance() + if (km == null) { + ctx.fail(result, "SDK not ready") + return + } + km.setValue(key, value, object : SetCallback { + override fun onSuccess() { + ctx.mainHandler.post { result.success(null) } + } + + override fun onFailure(error: DJIError) { + ctx.mainHandler.post { result.error("DJI_ERROR", error.description, null) } + } + }) + } + + private fun meteringGrid(camera: Camera): Pair { + val km = KeyManager.getInstance() + val i = index(camera) + val cols = (safe { km?.getValue(CameraKey.create(CameraKey.SPOT_METERING_COL_NUMS, i)) } as? Number)?.toInt() + val rows = (safe { km?.getValue(CameraKey.create(CameraKey.SPOT_METERING_ROW_NUMS, i)) } as? Number)?.toInt() + return Pair(cols?.takeIf { it > 0 } ?: 12, rows?.takeIf { it > 0 } ?: 8) + } + + private fun unit(v: Double?): Float = (v ?: 0.5).toFloat().coerceIn(0f, 1f) + override fun bind(product: BaseProduct?) { if (product !is Aircraft) return val camera = product.camera ?: return @@ -143,30 +304,89 @@ class CameraBridge(private val ctx: BridgeCtx) : SubBridge { } } } - - val index = try { - camera.index - } catch (_: Throwable) { - 0 + safe { + camera.setHistogramCallback { bins: ShortArray -> + val now = System.currentTimeMillis() + if (now - lastHistogramAt >= 250) { + lastHistogramAt = now + val values = bins.map { it.toInt() } + ctx.mainHandler.post { ctx.emit(mapOf("type" to "histogram", "bins" to values)) } + } + } } + + val index = index(camera) + val optical = safe { camera.isOpticalZoomSupported } == true + val hybrid = safe { camera.isHybridZoomSupported } == true + val digital = safe { camera.isDigitalZoomSupported } == true ctx.mainHandler.post { keys.clear() - fun setting(name: String, field: String) = keys.watch(CameraKey.create(name, index)) { v -> - ctx.hub.patchSlow("cameraSettings", mapOf(field to KeyValues.toDart(v))) - } - setting(CameraKey.AE_LOCK, "aeLock") - setting(CameraKey.FOCUS_MODE, "focusMode") - setting(CameraKey.PHOTO_ASPECT_RATIO, "photoAspectRatio") - setting(CameraKey.PHOTO_FILE_FORMAT, "photoFileFormat") - setting(CameraKey.VIDEO_FILE_FORMAT, "videoFileFormat") - setting(CameraKey.CAMERA_COLOR, "colorProfile") - keys.watch(CameraKey.create(CameraKey.RESOLUTION_FRAME_RATE, index)) { v -> + fun patch(values: Map) = ctx.hub.patchSlow("cameraSettings", values) + fun watch(key: String, onValue: (Any?) -> Unit) = keys.watch(CameraKey.create(key, index), onValue) + + for ((id, s) in settings) watch(s.key) { v -> patch(mapOf(id to KeyValues.toDart(v))) } + // The ISO *setting* (AUTO included); `exposure.iso` is what the camera metered. + watch(CameraKey.ISO) { v -> patch(mapOf("isoSetting" to isoLabel((v as? Enum<*>)?.name))) } + watch(CameraKey.RESOLUTION_FRAME_RATE) { v -> val r = v as? ResolutionAndFrameRate - ctx.hub.patchSlow( - "cameraSettings", - mapOf("videoResolution" to r?.resolution?.name, "videoFrameRate" to r?.frameRate?.name), + patch(mapOf("videoResolution" to r?.resolution?.name, "videoFrameRate" to r?.frameRate?.name)) + } + watch(CameraKey.WHITE_BALANCE) { v -> + val wb = v as? WhiteBalance + patch( + mapOf( + "whiteBalancePreset" to wb?.whiteBalancePreset?.name, + // The SDK counts colour temperature in units of 100 K. + "whiteBalanceKelvin" to wb?.colorTemperature?.takeIf { it > 0 }?.times(100), + ) ) } + watch(CameraKey.PHOTO_TIME_INTERVAL_SETTINGS) { v -> + val t = v as? SettingsDefinitions.PhotoTimeIntervalSettings + patch(mapOf("intervalCount" to t?.captureCount, "intervalSeconds" to t?.timeIntervalInSeconds)) + } + watch(CameraKey.PICTURE_STYLE_PRESET) { v -> + val p = v as? SettingsDefinitions.PictureStylePreset + patch(mapOf("styleSharpness" to p?.sharpness, "styleContrast" to p?.contrast, "styleSaturation" to p?.saturation)) + } + watch(CameraKey.SPOT_METERING_TARGET) { v -> + val p = v as? Point + patch(mapOf("spotCol" to p?.x, "spotRow" to p?.y)) + } + watch(CameraKey.FOCUS_TARGET) { v -> + val p = v as? PointF + patch(mapOf("focusX" to p?.x?.toDouble(), "focusY" to p?.y?.toDouble())) + } + if (hybrid) { + watch(CameraKey.HYBRID_ZOOM_SPEC) { v -> + val z = v as? SettingsDefinitions.HybridZoomSpec + patch( + mapOf( + "zoomMin" to z?.minHybridFocalLength, + "zoomMax" to z?.maxHybridFocalLength, + "zoomOpticalMax" to z?.maxOpticalFocalLength, + "zoomStep" to z?.focalLengthStep, + ) + ) + } + watch(CameraKey.HYBRID_ZOOM_FOCAL_LENGTH) { v -> patch(mapOf("zoomFocalLength" to (v as? Number)?.toInt())) } + } else if (optical) { + watch(CameraKey.OPTICAL_ZOOM_SPEC) { v -> + val z = v as? SettingsDefinitions.OpticalZoomSpec + patch( + mapOf( + "zoomMin" to z?.minFocalLength, + "zoomMax" to z?.maxFocalLength, + "zoomOpticalMax" to z?.maxFocalLength, + "zoomStep" to z?.focalLengthStep, + ) + ) + } + watch(CameraKey.OPTICAL_ZOOM_FOCAL_LENGTH) { v -> patch(mapOf("zoomFocalLength" to (v as? Number)?.toInt())) } + } + if (digital) { + watch(CameraKey.DIGITAL_ZOOM_FACTOR) { v -> patch(mapOf("digitalZoomFactor" to (v as? Number)?.toDouble())) } + } } } @@ -202,6 +422,14 @@ class CameraBridge(private val ctx: BridgeCtx) : SubBridge { "isRecording" to state.isRecording, "recordingTimeSeconds" to state.currentVideoRecordingTimeInSeconds, "mode" to state.mode?.name, + "shootingSingle" to state.isShootingSinglePhoto, + "shootingInterval" to state.isShootingIntervalPhoto, + "shootingBurst" to (state.isShootingBurstPhoto || state.isShootingRAWBurstPhoto), + "shootingPanorama" to state.isShootingPanoramaPhoto, + "panoramaProgress" to state.panoramaProgress, + "storingPhoto" to state.isStoringPhoto, + "overheating" to state.isOverheating, + "hasError" to state.hasError(), ) ) } @@ -213,6 +441,7 @@ class CameraBridge(private val ctx: BridgeCtx) : SubBridge { "iso" to s.iso.toString(), "shutter" to shutterLabel(s.shutterSpeed?.name), "aperture" to apertureLabel(s.aperture?.name), + // In manual exposure this is the meter's offset from a correct exposure. "ev" to evLabel(s.exposureCompensation?.name), ) ) @@ -240,10 +469,12 @@ class CameraBridge(private val ctx: BridgeCtx) : SubBridge { return (if (neg) "N_" else "P_") + digits } + /** `AUTO`, a preset name (`SUNNY`, `CLOUDY`, …) or a custom temperature (`5600K`). */ private fun whiteBalance(v: String?): WhiteBalance? { if (v == null) return null - if (v.equals("AUTO", true)) { - return WhiteBalance(SettingsDefinitions.WhiteBalancePreset.AUTO) + val preset = enumOrNull(v.uppercase()) + if (preset != null && preset != SettingsDefinitions.WhiteBalancePreset.CUSTOM) { + return WhiteBalance(preset) } val kelvin = v.replace("K", "", true).trim().toIntOrNull() ?: return null // DJI custom colour temperature is expressed in units of 100 K. diff --git a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/ProductProfileBridge.kt b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/ProductProfileBridge.kt index 7b34a3b..2d39c1e 100644 --- a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/ProductProfileBridge.kt +++ b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/ProductProfileBridge.kt @@ -2,6 +2,8 @@ package com.pilotvault.flyapp import dji.common.gimbal.CapabilityKey import dji.common.util.DJIParamMinMaxCapability +import dji.keysdk.CameraKey +import dji.keysdk.KeyManager import dji.sdk.base.BaseProduct import dji.sdk.camera.Camera import dji.sdk.gimbal.Gimbal @@ -163,9 +165,29 @@ class ProductProfileBridge(private val ctx: BridgeCtx) : SubBridge { "$res@$fps" } .distinct(), + // Ranges `Capabilities` has no getter for, from the key cache (empty + // until the handshake, like the ones above). The camera menu (plan P4) + // builds its option lists from these. + "photoModes" to cachedNames(cam, CameraKey.SHOOT_PHOTO_MODE_RANGE), + "whiteBalancePresets" to cachedNames(cam, CameraKey.WHITE_BALANCE_PRESENT_RANGE), + "antiFlickerModes" to cachedNames(cam, CameraKey.ANTI_FLICKER_RANGE), + "videoStandards" to cachedNames(cam, CameraKey.VIDEO_STANDARD_RANGE), + "colors" to cachedNames(cam, CameraKey.CAMERA_COLOR_RANGE), ) } + /** Enum names of a cached range key (an array or list of enums), UNKNOWN dropped. */ + private fun cachedNames(cam: Camera, key: String): List { + val index = probe { cam.index } ?: 0 + val v = probe { KeyManager.getInstance()?.getValue(CameraKey.create(key, index)) } + val items: List = when (v) { + is Array<*> -> v.toList() + is Collection<*> -> v.toList() + else -> emptyList() + } + return items.mapNotNull { (it as? Enum<*>)?.name }.filter { it != "UNKNOWN" }.distinct() + } + /** Enum names (or their setter labels) with the SDK's UNKNOWN placeholders dropped. */ private fun names(values: Array?>?, label: (String?) -> String? = { it }): List = values.orEmpty() diff --git a/Fly App/lib/dji_service.dart b/Fly App/lib/dji_service.dart index d3cb844..f1fe0ea 100644 --- a/Fly App/lib/dji_service.dart +++ b/Fly App/lib/dji_service.dart @@ -11,7 +11,7 @@ import 'package:flutter/services.dart'; /// native side answers with a typed error the UI can surface. The event stream /// carries maps keyed by `type`: `registration`, `connection`, `identity`, /// `telemetry` (the batched snapshot — see `TelemetryReducer`), `camera`, -/// `exposure`, `diagnostics`, `mission`, `key`, `mediaList`, `mediaDownload`, +/// `exposure`, `histogram`, `diagnostics`, `mission`, `key`, `mediaList`, `mediaDownload`, /// `djiAccount`, `productProfile`, `database`, `init`. class DjiService { static const MethodChannel _methods = MethodChannel('dji_msdk/methods'); @@ -149,6 +149,53 @@ class DjiService { Future setHistogramEnabled(bool on) => _methods.invokeMethod('setHistogramEnabled', {'value': on}); + /// One simple camera setting by its `cameraSettings` id (`photoAspectRatio`, + /// `exposureMode`, `aeLock`, …) — see the table in `CameraBridge.kt`. [value] + /// is the SDK enum name, or a bool. + Future setCameraValue(String id, Object value) => + _methods.invokeMethod('setCameraValue', {'id': id, 'value': value}); + + /// resolution / frameRate: SDK enum names (`RESOLUTION_3840x2160`, `FRAME_RATE_30_FPS`). + Future setVideoResolutionAndFrameRate(String resolution, String frameRate) => + _methods.invokeMethod('setVideoResolutionAndFrameRate', { + 'resolution': resolution, + 'frameRate': frameRate, + }); + + /// Interval shooting: [count] photos (255 = until stopped) every [seconds]. + Future setPhotoTimeInterval({required int count, required int seconds}) => + _methods.invokeMethod('setPhotoTimeInterval', {'count': count, 'seconds': seconds}); + + /// The picture style, each −3…+3 on most cameras. + Future setPictureStyle({required int sharpness, required int contrast, required int saturation}) => + _methods.invokeMethod('setPictureStyle', { + 'sharpness': sharpness, + 'contrast': contrast, + 'saturation': saturation, + }); + + /// Tap-to-focus at x/y, each 0..1 across the live view. + Future setFocusTarget(double x, double y) => + _methods.invokeMethod('setFocusTarget', {'x': x, 'y': y}); + + /// Spot metering at x/y (0..1); switches the metering mode to SPOT first. + Future setSpotMeteringTarget(double x, double y) => + _methods.invokeMethod('setSpotMeteringTarget', {'x': x, 'y': y}); + + /// Optical zoom while a T / W button is held. direction: `in` | `out`. + Future startContinuousZoom(String direction) => + _methods.invokeMethod('startContinuousZoom', {'direction': direction}); + Future stopContinuousZoom() => _methods.invokeMethod('stopContinuousZoom'); + + /// Hybrid or optical focal length, in the units `cameraSettings.zoom*` report. + Future setZoomFocalLength(int value) => + _methods.invokeMethod('setZoomFocalLength', {'value': value}); + Future setDigitalZoomFactor(double value) => + _methods.invokeMethod('setDigitalZoomFactor', {'value': value}); + + /// The camera's own "Reset camera settings". + Future restoreCameraDefaults() => _methods.invokeMethod('restoreCameraDefaults'); + // ── Gimbal ────────────────────────────────────────────────────────────────── Future rotateGimbalPitch(double deg) => _methods.invokeMethod('rotateGimbalPitch', {'pitch': deg}); diff --git a/Fly App/lib/fly/camera_exposure.dart b/Fly App/lib/fly/camera_exposure.dart new file mode 100644 index 0000000..c9952fc --- /dev/null +++ b/Fly App/lib/fly/camera_exposure.dart @@ -0,0 +1,142 @@ +import 'product_profile.dart'; +import 'state/camera_state.dart'; + +/// GO 4's four exposure modes (plan P4 quick exposure panel). The wire value is +/// the SDK's `ExposureMode` name, sent through `setCameraValue('exposureMode')`. +enum ExposureMode { + auto('PROGRAM', 'Auto'), + aperture('APERTURE_PRIORITY', 'A'), + shutter('SHUTTER_PRIORITY', 'S'), + manual('MANUAL', 'M'); + + const ExposureMode(this.wire, this.label); + + final String wire; + final String label; + + static ExposureMode? fromWire(String? wire) { + for (final ExposureMode m in values) { + if (m.wire == wire) return m; + } + return null; + } + + /// What the pilot may set by hand in this mode. In M the EV readout is the + /// meter's verdict rather than a setting. + bool get isoEditable => this != auto; + bool get shutterEditable => this == shutter || this == manual; + bool get apertureEditable => this == aperture || this == manual; + bool get evEditable => this != manual; +} + +/// The option lists the exposure controls offer, from the camera's own +/// capability ranges (P0) and a conservative fallback while those are still +/// empty (before the camera's handshake, or with nothing connected). +abstract final class ExposureOptions { + static const List _isos = ['100', '200', '400', '800', '1600', '3200']; + static const List _shutters = [ + '1/8000', '1/4000', '1/2000', '1/1000', '1/500', '1/240', '1/120', '1/60', '1/30', '1/15', '1/8', '1/4', '1/2', '1', + ]; + static const List _evs = [ + '-3.0', '-2.7', '-2.3', '-2.0', '-1.7', '-1.3', '-1.0', '-0.7', '-0.3', '0.0', // + '+0.3', '+0.7', '+1.0', '+1.3', '+1.7', '+2.0', '+2.3', '+2.7', '+3.0', + ]; + + /// The modes the segmented control shows: the camera's own list, with A only + /// on adjustable-aperture lenses. + static List modes(CameraProfile? c) { + final List reported = [ + for (final String w in c?.exposureModes ?? const []) + if (ExposureMode.fromWire(w) case final ExposureMode m) m, + ]; + final List all = reported.isEmpty ? ExposureMode.values : reported; + final bool aperture = c == null || c.adjustableApertureSupported; + return [ + for (final ExposureMode m in ExposureMode.values) + if (all.contains(m) && (m != ExposureMode.aperture || aperture)) m, + ]; + } + + /// Numeric ISOs for the slider; `AUTO` is its own toggle. + static List isos(CameraProfile? c) { + final List l = [for (final String v in c?.isos ?? const []) if (int.tryParse(v) != null) v]; + return l.isEmpty ? _isos : l; + } + + static bool isoAutoAvailable(CameraProfile? c) => c == null || c.isos.isEmpty || c.isos.contains('AUTO'); + + /// Fastest first, as the SDK lists them. + static List shutters(CameraProfile? c) => + (c?.shutterSpeeds.isNotEmpty ?? false) ? c!.shutterSpeeds : _shutters; + + static List apertures(CameraProfile? c) => c?.apertures ?? const []; + + /// Lowest first. + static List evs(CameraProfile? c) { + final List l = (c?.exposureCompensations.isNotEmpty ?? false) ? c!.exposureCompensations : _evs; + return List.of(l)..sort((String a, String b) => (evValue(a) ?? 0).compareTo(evValue(b) ?? 0)); + } + + /// The EV one press of − / + lands on, or null at the end of the range. + static String? stepEv(String? current, List evs, int direction) { + if (evs.isEmpty) return null; + final double now = evValue(current) ?? 0; + if (direction > 0) { + for (final String e in evs) { + if ((evValue(e) ?? 0) > now + 0.05) return e; + } + } else { + for (final String e in evs.reversed) { + if ((evValue(e) ?? 0) < now - 0.05) return e; + } + } + return null; + } + + /// `+0.7` → 0.7, `0.0` / `0` → 0. + static double? evValue(String? label) => label == null ? null : double.tryParse(label.replaceFirst('+', '')); + + /// The index in [values] closest to [current], for a wheel to start on. + static int nearest(List values, String? current) { + if (values.isEmpty) return 0; + final int exact = current == null ? -1 : values.indexOf(current); + if (exact >= 0) return exact; + final double? v = _numeric(current); + if (v == null) return 0; + int best = 0; + double bestD = double.infinity; + for (int i = 0; i < values.length; i++) { + final double? x = _numeric(values[i]); + if (x == null) continue; + final double d = (x - v).abs(); + if (d < bestD) { + bestD = d; + best = i; + } + } + return best; + } + + /// `1/240` → 0.0041, `2.5` → 2.5, `f/2.8` → 2.8, `400` → 400. + static double? _numeric(String? s) { + if (s == null) return null; + final String t = s.replaceFirst('f/', '').replaceAll('"', ''); + final int slash = t.indexOf('/'); + if (slash > 0) { + final double? n = double.tryParse(t.substring(0, slash)), d = double.tryParse(t.substring(slash + 1)); + return n == null || d == null || d == 0 ? null : n / d; + } + return double.tryParse(t); + } +} + +/// How far the meter is from a correct exposure in M, for the indicator: +/// within a third of a stop is fine, past two-thirds is worth a colour. +enum MeterVerdict { ok, under, over } + +MeterVerdict meterVerdict(CameraState c) { + final double ev = ExposureOptions.evValue(c.ev) ?? 0; + if (ev <= -0.7) return MeterVerdict.under; + if (ev >= 0.7) return MeterVerdict.over; + return MeterVerdict.ok; +} diff --git a/Fly App/lib/fly/camera_menu.dart b/Fly App/lib/fly/camera_menu.dart new file mode 100644 index 0000000..fc17001 --- /dev/null +++ b/Fly App/lib/fly/camera_menu.dart @@ -0,0 +1,579 @@ +import '../dji_service.dart'; +import 'product_profile.dart'; +import 'state/camera_state.dart'; +import 'state/parse.dart'; + +/// The camera menu's rows (plan P4), as data: each tab is a list of row specs +/// bound to one camera setting, so adding a setting is one entry here plus, +/// if it is new to the bridge, one line in `CameraBridge.kt`'s settings table. +/// +/// Rows read what the camera *reported* ([CameraState.settings], with the +/// menu's pending writes on top) and are built from its capability ranges where +/// it publishes them. GO 4 hides rows per product, and so do we, in two ways: +/// a row's [CameraRow.requires] predicate on the [ProductProfile], and — once +/// the camera has reported its settings — any row whose setting it never +/// reported, because that key does not exist on this camera. +enum CameraMenuTab { + exposure('Exposure'), + photo('Photo'), + video('Video'), + other('Other'); + + const CameraMenuTab(this.label); + final String label; +} + +/// One choice of a [ChoiceRow]: the wire value and its label. +class CameraOption { + const CameraOption(this.value, this.label); + final String value; + final String label; + + @override + bool operator ==(Object other) => other is CameraOption && other.value == value && other.label == label; + + @override + int get hashCode => Object.hash(value, label); + + @override + String toString() => '$value ($label)'; +} + +typedef CameraPredicate = bool Function(ProductProfile p, CameraState c); + +sealed class CameraRow { + const CameraRow({required this.id, required this.label, required this.tab, this.requires, this.reportedBy}); + + final String id; + final String label; + final CameraMenuTab tab; + + /// Product / state condition for the row to exist at all. + final CameraPredicate? requires; + + /// The `cameraSettings` key whose presence proves the camera has this + /// setting; null for rows that are always there (actions). + final String? reportedBy; + + /// Whether the row shows for [p] / [c]. + bool visible(ProductProfile p, CameraState c) { + if (p.connected && requires != null && !requires!(p, c)) return false; + // Before the first report nothing is known; show the row as a preview. + final String? key = reportedBy; + if (key == null || !p.connected || c.reportedSettings.isEmpty) return true; + return c.reportedSettings[key] != null; + } +} + +/// Pick one of several values. +class ChoiceRow extends CameraRow { + const ChoiceRow({ + required super.id, + required super.label, + required super.tab, + required this.options, + required this.current, + required this.apply, + this.pendingFor, + this.pendingKeys, + super.requires, + super.reportedBy, + }); + + final List Function(ProductProfile p, CameraState c) options; + final String? Function(CameraState c) current; + final Future Function(DjiService dji, CameraState c, String value) apply; + + /// The settings to show as pending while [apply] runs; `{id: value}` by default. + final Map Function(String value)? pendingFor; + + /// The settings [pendingFor] writes; `[id]` by default. + final List? pendingKeys; + + Map pending(String value) => pendingFor?.call(value) ?? {id: value}; + + List get writes => pendingKeys ?? [id]; +} + +/// On / off. +class SwitchRow extends CameraRow { + const SwitchRow({ + required super.id, + required super.label, + required super.tab, + required this.apply, + super.requires, + super.reportedBy, + }); + + final Future Function(DjiService dji, bool on) apply; + + bool? current(CameraState c) => asBool(c.setting(id)); +} + +/// A number with − / + (custom white balance, picture style). +class StepperRow extends CameraRow { + const StepperRow({ + required super.id, + required super.label, + required super.tab, + required this.min, + required this.max, + required this.step, + required this.apply, + this.unit = '', + super.requires, + super.reportedBy, + }); + + final int min; + final int max; + final int step; + final String unit; + final Future Function(DjiService dji, CameraState c, int value) apply; + + int? current(CameraState c) => asInt(c.setting(id)); +} + +/// A button, optionally behind a slide-to-confirm. +class ActionRow extends CameraRow { + const ActionRow({ + required super.id, + required super.label, + required super.tab, + required this.button, + required this.run, + this.confirmTitle, + this.confirmText, + this.danger = false, + this.enabled, + super.requires, + }); + + final String button; + final Future Function(DjiService dji) run; + final String? confirmTitle; + final String? confirmText; + final bool danger; + + /// Greyed out (with the reason) while false, e.g. Format during recording. + final String? Function(CameraState c)? enabled; + + /// Why the action is unavailable right now, or null when it can run. + String? blockedReason(CameraState c) => enabled?.call(c); +} + +abstract final class CameraMenu { + static List rowsFor(CameraMenuTab tab, ProductProfile p, CameraState c) => [ + for (final CameraRow r in all) + if (r.tab == tab && r.visible(p, c)) r, + ]; + + // ── Option helpers ───────────────────────────────────────────────────────── + static List _opts(Iterable values) => + [for (final String v in values) CameraOption(v, CameraLabels.of(v))]; + + /// The camera's own range when it published one, else [fallback]. + static List _ranged(List? reported, List fallback) => + _opts((reported != null && reported.isNotEmpty) ? reported : fallback); + + static Future _set(DjiService dji, String id, Object value) => dji.setCameraValue(id, value); + + static bool _mode(CameraState c, String mode) => c.shootPhotoMode == null || c.shootPhotoMode == mode; + + // ── Rows ─────────────────────────────────────────────────────────────────── + static final List all = [ + // Exposure (below the quick exposure controls). + ChoiceRow( + id: 'meteringMode', + label: 'Metering', + tab: CameraMenuTab.exposure, + reportedBy: 'meteringMode', + requires: (ProductProfile p, _) => p.camera?.meteringSupported ?? false, + options: (_, __) => _opts(const ['CENTER', 'AVERAGE', 'SPOT']), + current: (CameraState c) => c.meteringMode, + apply: (DjiService d, _, String v) => _set(d, 'meteringMode', v), + ), + ChoiceRow( + id: 'focusMode', + label: 'Focus', + tab: CameraMenuTab.exposure, + reportedBy: 'focusMode', + requires: (ProductProfile p, _) => p.camera?.adjustableFocalPointSupported ?? false, + options: (_, __) => const [CameraOption('AUTO', 'AF'), CameraOption('AFC', 'AF-C'), CameraOption('MANUAL', 'MF')], + current: (CameraState c) => asString(c.setting('focusMode')), + apply: (DjiService d, _, String v) => _set(d, 'focusMode', v), + ), + SwitchRow( + id: 'aeLock', + label: 'AE lock', + tab: CameraMenuTab.exposure, + reportedBy: 'aeLock', + apply: (DjiService d, bool on) => _set(d, 'aeLock', on), + ), + + // Photo. + ChoiceRow( + id: 'shootPhotoMode', + label: 'Photo mode', + tab: CameraMenuTab.photo, + reportedBy: 'shootPhotoMode', + options: (ProductProfile p, _) => + _ranged(p.camera?.photoModes, const ['SINGLE', 'HDR', 'BURST', 'AEB', 'INTERVAL', 'PANORAMA']), + current: (CameraState c) => c.shootPhotoMode, + apply: (DjiService d, _, String v) => _set(d, 'shootPhotoMode', v), + ), + ChoiceRow( + id: 'burstCount', + label: 'Burst', + tab: CameraMenuTab.photo, + reportedBy: 'burstCount', + requires: (_, CameraState c) => _mode(c, 'BURST'), + options: (_, __) => _opts(const ['BURST_COUNT_3', 'BURST_COUNT_5', 'BURST_COUNT_7', 'BURST_COUNT_10', 'BURST_COUNT_14']), + current: (CameraState c) => asString(c.setting('burstCount')), + apply: (DjiService d, _, String v) => _set(d, 'burstCount', v), + ), + ChoiceRow( + id: 'aebCount', + label: 'AEB', + tab: CameraMenuTab.photo, + reportedBy: 'aebCount', + requires: (_, CameraState c) => _mode(c, 'AEB'), + options: (_, __) => _opts(const ['AEB_COUNT_3', 'AEB_COUNT_5', 'AEB_COUNT_7']), + current: (CameraState c) => asString(c.setting('aebCount')), + apply: (DjiService d, _, String v) => _set(d, 'aebCount', v), + ), + ChoiceRow( + id: 'intervalSeconds', + label: 'Interval', + tab: CameraMenuTab.photo, + reportedBy: 'intervalSeconds', + requires: (_, CameraState c) => _mode(c, 'INTERVAL'), + options: (_, __) => [ + for (final int s in const [2, 3, 5, 7, 10, 15, 20, 30, 60]) CameraOption('$s', '${s}s'), + ], + current: (CameraState c) => c.intervalSeconds?.toString(), + // 255 photos = until stopped, GO 4's default. + apply: (DjiService d, CameraState c, String v) => + d.setPhotoTimeInterval(count: c.intervalCount ?? 255, seconds: int.parse(v)), + pendingFor: (String v) => {'intervalSeconds': int.parse(v)}, + ), + ChoiceRow( + id: 'panoramaMode', + label: 'Panorama', + tab: CameraMenuTab.photo, + reportedBy: 'panoramaMode', + requires: (_, CameraState c) => _mode(c, 'PANORAMA'), + options: (_, __) => _opts(const [ + 'PANORAMA_MODE_SPHERE', + 'PANORAMA_MODE_180', + 'PANORAMA_MODE_3X1', + 'PANORAMA_MODE_1X3', + 'PANORAMA_MODE_3X3', + ]), + current: (CameraState c) => asString(c.setting('panoramaMode')), + apply: (DjiService d, _, String v) => _set(d, 'panoramaMode', v), + ), + ChoiceRow( + id: 'photoAspectRatio', + label: 'Image size', + tab: CameraMenuTab.photo, + reportedBy: 'photoAspectRatio', + options: (_, __) => _opts(const ['RATIO_4_3', 'RATIO_16_9', 'RATIO_3_2']), + current: (CameraState c) => c.photoAspectRatio, + apply: (DjiService d, _, String v) => _set(d, 'photoAspectRatio', v), + ), + ChoiceRow( + id: 'photoFileFormat', + label: 'Image format', + tab: CameraMenuTab.photo, + reportedBy: 'photoFileFormat', + options: (ProductProfile p, _) => _opts([ + 'JPEG', + 'RAW', + 'RAW_AND_JPEG', + if (p.hasThermal) ...['TIFF_14_BIT', 'RADIOMETRIC_JPEG'], + ]), + current: (CameraState c) => c.photoFileFormat, + apply: (DjiService d, _, String v) => _set(d, 'photoFileFormat', v), + ), + + // Video. + ChoiceRow( + id: 'videoSize', + label: 'Video size', + tab: CameraMenuTab.video, + reportedBy: 'videoResolution', + requires: (ProductProfile p, _) => p.camera?.videoResolutions.isNotEmpty ?? false, + options: (ProductProfile p, _) => [ + for (final String v in p.camera?.videoResolutions ?? const []) CameraOption(v, CameraLabels.videoSize(v)), + ], + current: (CameraState c) => + c.videoResolution == null || c.videoFrameRate == null ? null : '${c.videoResolution}@${c.videoFrameRate}', + apply: (DjiService d, _, String v) { + final List parts = v.split('@'); + return d.setVideoResolutionAndFrameRate(parts.first, parts.last); + }, + pendingFor: (String v) { + final List parts = v.split('@'); + return {'videoResolution': parts.first, 'videoFrameRate': parts.last}; + }, + pendingKeys: const ['videoResolution', 'videoFrameRate'], + ), + ChoiceRow( + id: 'videoFileFormat', + label: 'Video format', + tab: CameraMenuTab.video, + reportedBy: 'videoFileFormat', + options: (_, __) => _opts(const ['MOV', 'MP4']), + current: (CameraState c) => c.videoFileFormat, + apply: (DjiService d, _, String v) => _set(d, 'videoFileFormat', v), + ), + ChoiceRow( + id: 'videoStandard', + label: 'NTSC / PAL', + tab: CameraMenuTab.video, + reportedBy: 'videoStandard', + options: (ProductProfile p, _) => _ranged(p.camera?.videoStandards, const ['NTSC', 'PAL']), + current: (CameraState c) => asString(c.setting('videoStandard')), + apply: (DjiService d, _, String v) => _set(d, 'videoStandard', v), + ), + ChoiceRow( + id: 'whiteBalancePreset', + label: 'White balance', + tab: CameraMenuTab.video, + reportedBy: 'whiteBalancePreset', + options: (ProductProfile p, _) => _ranged( + p.camera?.whiteBalancePresets, + const ['AUTO', 'SUNNY', 'CLOUDY', 'INDOOR_INCANDESCENT', 'INDOOR_FLUORESCENT', 'CUSTOM'], + ), + current: (CameraState c) => c.whiteBalancePreset, + // CUSTOM keeps the last temperature (or 5600 K) until the stepper moves it. + apply: (DjiService d, CameraState c, String v) => + d.setWhiteBalance(v == 'CUSTOM' ? '${c.whiteBalanceKelvin ?? 5600}K' : v), + ), + StepperRow( + id: 'whiteBalanceKelvin', + label: 'Colour temperature', + tab: CameraMenuTab.video, + reportedBy: 'whiteBalancePreset', + requires: (_, CameraState c) => c.whiteBalancePreset == 'CUSTOM', + min: 2000, + max: 10000, + step: 100, + unit: 'K', + apply: (DjiService d, _, int v) => d.setWhiteBalance('${v}K'), + ), + StepperRow( + id: 'styleSharpness', + label: 'Sharpness', + tab: CameraMenuTab.video, + reportedBy: 'styleSharpness', + min: -3, + max: 3, + step: 1, + apply: (DjiService d, CameraState c, int v) => _style(d, c, sharpness: v), + ), + StepperRow( + id: 'styleContrast', + label: 'Contrast', + tab: CameraMenuTab.video, + reportedBy: 'styleContrast', + min: -3, + max: 3, + step: 1, + apply: (DjiService d, CameraState c, int v) => _style(d, c, contrast: v), + ), + StepperRow( + id: 'styleSaturation', + label: 'Saturation', + tab: CameraMenuTab.video, + reportedBy: 'styleSaturation', + min: -3, + max: 3, + step: 1, + apply: (DjiService d, CameraState c, int v) => _style(d, c, saturation: v), + ), + ChoiceRow( + id: 'colorProfile', + label: 'Colour', + tab: CameraMenuTab.video, + reportedBy: 'colorProfile', + options: (ProductProfile p, _) => _ranged(p.camera?.colors, const ['NONE', 'D_LOG', 'D_CINELIKE']), + current: (CameraState c) => c.colorProfile, + apply: (DjiService d, _, String v) => _set(d, 'colorProfile', v), + ), + SwitchRow( + id: 'videoCaption', + label: 'Video captions', + tab: CameraMenuTab.video, + reportedBy: 'videoCaption', + apply: (DjiService d, bool on) => _set(d, 'videoCaption', on), + ), + + // Other. + SwitchRow( + id: 'histogramEnabled', + label: 'Histogram', + tab: CameraMenuTab.other, + reportedBy: 'histogramEnabled', + apply: (DjiService d, bool on) => d.setHistogramEnabled(on), + ), + SwitchRow( + id: 'autoLockGimbal', + label: 'Lock gimbal while shooting', + tab: CameraMenuTab.other, + reportedBy: 'autoLockGimbal', + requires: (ProductProfile p, _) => p.hasGimbal, + apply: (DjiService d, bool on) => _set(d, 'autoLockGimbal', on), + ), + ChoiceRow( + id: 'antiFlicker', + label: 'Anti-flicker', + tab: CameraMenuTab.other, + reportedBy: 'antiFlicker', + options: (ProductProfile p, _) => + _ranged(p.camera?.antiFlickerModes, const ['AUTO', 'MANUAL_50HZ', 'MANUAL_60HZ']), + current: (CameraState c) => asString(c.setting('antiFlicker')), + apply: (DjiService d, _, String v) => _set(d, 'antiFlicker', v), + ), + ChoiceRow( + id: 'fileIndexMode', + label: 'File index', + tab: CameraMenuTab.other, + reportedBy: 'fileIndexMode', + options: (_, __) => _opts(const ['SEQUENCE', 'RESET']), + current: (CameraState c) => asString(c.setting('fileIndexMode')), + apply: (DjiService d, _, String v) => _set(d, 'fileIndexMode', v), + ), + ActionRow( + id: 'formatSd', + label: 'Format SD card', + tab: CameraMenuTab.other, + button: 'Format', + danger: true, + confirmTitle: 'Format SD card', + confirmText: 'Every photo and video on the card is erased. This cannot be undone.', + enabled: (CameraState c) { + if (c.isRecording || c.shootingContinuous) return 'Stop shooting first'; + if (c.storage != null && !c.storage!.inserted) return 'No SD card'; + return null; + }, + run: (DjiService d) => d.formatStorage(), + ), + ActionRow( + id: 'resetCamera', + label: 'Reset camera settings', + tab: CameraMenuTab.other, + button: 'Reset', + confirmTitle: 'Reset camera settings', + confirmText: 'Every camera setting goes back to its factory default. Photos and videos are kept.', + enabled: (CameraState c) => c.isRecording || c.shootingContinuous ? 'Stop shooting first' : null, + run: (DjiService d) => d.restoreCameraDefaults(), + ), + ]; + + /// The style is one SDK value; send all three with the one that moved. + static Future _style(DjiService d, CameraState c, {int? sharpness, int? contrast, int? saturation}) => + d.setPictureStyle( + sharpness: sharpness ?? asInt(c.setting('styleSharpness')) ?? 0, + contrast: contrast ?? asInt(c.setting('styleContrast')) ?? 0, + saturation: saturation ?? asInt(c.setting('styleSaturation')) ?? 0, + ); +} + +/// Pilot-facing labels for the SDK's enum names. +abstract final class CameraLabels { + static const Map _known = { + 'SINGLE': 'Single', + 'HDR': 'HDR', + 'EHDR': 'Enhanced HDR', + 'BURST': 'Burst', + 'RAW_BURST': 'RAW burst', + 'AEB': 'AEB', + 'INTERVAL': 'Interval', + 'TIME_LAPSE': 'Time-lapse', + 'PANORAMA': 'Pano', + 'HYPER_LIGHT': 'HyperLight', + 'HYPER_LAPSE': 'Hyperlapse', + 'SHALLOW_FOCUS': 'Shallow focus', + 'HIGH_RESOLUTION': 'High resolution', + 'SUPER_RESOLUTION': 'Super resolution', + 'RATIO_4_3': '4:3', + 'RATIO_16_9': '16:9', + 'RATIO_3_2': '3:2', + 'RATIO_1_1': '1:1', + 'JPEG': 'JPEG', + 'RAW': 'RAW', + 'RAW_AND_JPEG': 'JPEG + RAW', + 'TIFF_14_BIT': 'TIFF', + 'RADIOMETRIC_JPEG': 'R-JPEG', + 'MOV': 'MOV', + 'MP4': 'MP4', + 'NTSC': 'NTSC', + 'PAL': 'PAL', + 'AUTO': 'Auto', + 'SUNNY': 'Sunny', + 'CLOUDY': 'Cloudy', + 'WATER_SURFACE': 'Water', + 'INDOOR_INCANDESCENT': 'Incandescent', + 'INDOOR_FLUORESCENT': 'Fluorescent', + 'PRESET_NEUTRAL': 'Neutral', + 'CUSTOM': 'Custom', + 'NONE': 'None', + 'D_LOG': 'D-Log', + 'D_CINELIKE': 'D-Cinelike', + 'TRUE_COLOR': 'True colour', + 'HLG': 'HLG', + 'BLACK_AND_WHITE': 'B&W', + 'ART': 'Art', + 'CENTER': 'Centre', + 'AVERAGE': 'Average', + 'SPOT': 'Spot', + 'MANUAL_50HZ': '50 Hz', + 'MANUAL_60HZ': '60 Hz', + 'DISABLED': 'Off', + 'SEQUENCE': 'Continuous', + 'RESET': 'Reset', + 'PANORAMA_MODE_SPHERE': 'Sphere', + 'PANORAMA_MODE_180': '180°', + 'PANORAMA_MODE_3X1': 'Wide', + 'PANORAMA_MODE_1X3': 'Vertical', + 'PANORAMA_MODE_3X3': '3×3', + }; + + static String of(String value) { + final String? k = _known[value]; + if (k != null) return k; + final RegExpMatch? count = RegExp(r'^(?:BURST|AEB)_COUNT_(\d+)$').firstMatch(value); + if (count != null) return count.group(1)!; + // FILM_A → Film a; anything else readable. + final String t = value.replaceAll('_', ' ').toLowerCase(); + return t.isEmpty ? value : t[0].toUpperCase() + t.substring(1); + } + + /// `RESOLUTION_3840x2160@FRAME_RATE_29_DOT_970_FPS` → `4K 29.97p`. + static String videoSize(String wire) { + final List parts = wire.split('@'); + final String res = parts.first.replaceFirst('RESOLUTION_', ''); + final String fps = parts.length > 1 ? fpsLabel(parts.last) : ''; + final String name = switch (res) { + '3840x2160' => '4K', + '4096x2160' => 'C4K', + '2720x1530' => '2.7K', + '1920x1080' => '1080p', + '1280x720' => '720p', + _ => res, + }; + return fps.isEmpty ? name : '$name $fps'; + } + + /// `FRAME_RATE_29_DOT_970_FPS` → `29.97p`. + static String fpsLabel(String wire) { + final String body = wire.replaceFirst('FRAME_RATE_', '').replaceFirst('_FPS', '').replaceAll('_DOT_', '.'); + final double? n = double.tryParse(body); + if (n == null) return body; + final String s = n == n.roundToDouble() ? n.toStringAsFixed(0) : n.toStringAsFixed(2).replaceFirst(RegExp(r'0+$'), ''); + return '${s}p'; + } +} diff --git a/Fly App/lib/fly/fly_prefs.dart b/Fly App/lib/fly/fly_prefs.dart index 5e96c38..080c5b6 100644 --- a/Fly App/lib/fly/fly_prefs.dart +++ b/Fly App/lib/fly/fly_prefs.dart @@ -1,10 +1,34 @@ import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; +/// The video overlay grid (camera menu → Other, and the rail's GRID button). +enum GridStyle { + off('Off'), + thirds('Grid'), + diagonals('Grid + diagonals'); + + const GridStyle(this.label); + final String label; +} + +/// What a long press on the live view does (GO 4's "Long Press" setting); a +/// single tap always focuses, or meters on fixed-focus cameras. +enum LongPressAction { + meter('Spot meter'), + focus('Focus'), + off('Nothing'); + + const LongPressAction(this.label); + final String label; +} + /// Device-local switches for the flight screen, persisted across launches. abstract final class FlyPrefs { static const String _classicKey = 'fly.classicHud'; static const String _perfKey = 'fly.perfOverlay'; + static const String _gridKey = 'fly.grid'; + static const String _centerKey = 'fly.centerPoint'; + static const String _longPressKey = 'fly.longPress'; /// Open the pre-P2 `FlightControlPage` instead of the HUD v2 — kept /// reachable until P3's safety flows pass their simulator run (plan P2/P3). @@ -14,6 +38,10 @@ abstract final class FlyPrefs { /// 10 Hz" check. static final ValueNotifier perfOverlay = ValueNotifier(false); + static final ValueNotifier grid = ValueNotifier(GridStyle.off); + static final ValueNotifier centerPoint = ValueNotifier(true); + static final ValueNotifier longPress = ValueNotifier(LongPressAction.meter); + static SharedPreferences? _prefs; static Future load() async { @@ -21,6 +49,9 @@ abstract final class FlyPrefs { _prefs = await SharedPreferences.getInstance(); classicHud.value = _prefs!.getBool(_classicKey) ?? false; perfOverlay.value = _prefs!.getBool(_perfKey) ?? false; + grid.value = _byName(GridStyle.values, _prefs!.getString(_gridKey)) ?? GridStyle.off; + centerPoint.value = _prefs!.getBool(_centerKey) ?? true; + longPress.value = _byName(LongPressAction.values, _prefs!.getString(_longPressKey)) ?? LongPressAction.meter; } catch (_) { // Storage unavailable: run on the defaults. } @@ -35,4 +66,26 @@ abstract final class FlyPrefs { perfOverlay.value = on; await _prefs?.setBool(_perfKey, on); } + + static Future setGrid(GridStyle g) async { + grid.value = g; + await _prefs?.setString(_gridKey, g.name); + } + + static Future setCenterPoint(bool on) async { + centerPoint.value = on; + await _prefs?.setBool(_centerKey, on); + } + + static Future setLongPress(LongPressAction a) async { + longPress.value = a; + await _prefs?.setString(_longPressKey, a.name); + } + + static T? _byName(List values, String? name) { + for (final T v in values) { + if (v.name == name) return v; + } + return null; + } } diff --git a/Fly App/lib/fly/hud_features.dart b/Fly App/lib/fly/hud_features.dart index d13438b..eb1c006 100644 --- a/Fly App/lib/fly/hud_features.dart +++ b/Fly App/lib/fly/hud_features.dart @@ -59,6 +59,12 @@ abstract final class HudFeatures { 'camera.thermal', 'Thermal readouts', 'Camera', (ProductProfile p) => p.hasThermal); static final HudFeature spotMetering = HudFeature( 'camera.metering', 'Tap-to-meter', 'Camera', (ProductProfile p) => p.camera?.meteringSupported == true); + static final HudFeature tapFocus = HudFeature( + '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); + static final HudFeature histogram = HudFeature( + 'camera.histogram', 'Histogram', 'Camera', (ProductProfile p) => p.hasCamera); // ── Gimbal ──────────────────────────────────────────────────────────────── static final HudFeature gimbalPitch = HudFeature( @@ -104,6 +110,9 @@ abstract final class HudFeatures { ssdStatus, thermalReadout, spotMetering, + tapFocus, + playback, + histogram, gimbalPitch, gimbalYaw, gimbalPitchExtension, diff --git a/Fly App/lib/fly/product_profile.dart b/Fly App/lib/fly/product_profile.dart index 56c3c51..ee5603a 100644 --- a/Fly App/lib/fly/product_profile.dart +++ b/Fly App/lib/fly/product_profile.dart @@ -47,6 +47,11 @@ class CameraProfile { this.apertures = const [], this.exposureCompensations = const [], this.videoResolutions = const [], + this.photoModes = const [], + this.whiteBalancePresets = const [], + this.antiFlickerModes = const [], + this.videoStandards = const [], + this.colors = const [], }); factory CameraProfile.fromMap(Map m) { @@ -79,6 +84,11 @@ class CameraProfile { apertures: l('apertures'), exposureCompensations: l('exposureCompensations'), videoResolutions: l('videoResolutions'), + photoModes: l('photoModes'), + whiteBalancePresets: l('whiteBalancePresets'), + antiFlickerModes: l('antiFlickerModes'), + videoStandards: l('videoStandards'), + colors: l('colors'), ); } @@ -107,8 +117,17 @@ class CameraProfile { final List shutterSpeeds; final List apertures; final List exposureCompensations; + + /// `RESOLUTION_3840x2160@FRAME_RATE_29_DOT_970_FPS` pairs. final List videoResolutions; + // SDK enum names, from the key cache's range keys. + final List photoModes; // SINGLE / HDR / BURST / AEB / INTERVAL / PANORAMA … + final List whiteBalancePresets; // AUTO / SUNNY / CLOUDY / … / CUSTOM + final List antiFlickerModes; // AUTO / MANUAL_50HZ / MANUAL_60HZ + final List videoStandards; // PAL / NTSC + final List colors; // NONE / D_LOG / D_CINELIKE / … + /// Optical or hybrid zoom — the cameras that get the zoom column (Mavic 2 /// Zoom, Z30). Digital-only zoom does not count. bool get hasZoom => opticalZoomSupported || hybridZoomSupported; diff --git a/Fly App/lib/fly/state/camera_state.dart b/Fly App/lib/fly/state/camera_state.dart index 6d8d287..1ddaf1e 100644 --- a/Fly App/lib/fly/state/camera_state.dart +++ b/Fly App/lib/fly/state/camera_state.dart @@ -107,6 +107,40 @@ class SsdInfo { bool get usable => status == 'OK'; } +/// Optical / hybrid zoom state. Focal lengths are in the camera's own units +/// (tenths of a millimetre on the Mavic 2 Zoom); [factor] is relative to [min]. +@immutable +class ZoomInfo { + const ZoomInfo({required this.focalLength, required this.min, required this.max, this.opticalMax, this.step}); + + static ZoomInfo? fromSettings(Map m) { + final int? f = asInt(m['zoomFocalLength']), lo = asInt(m['zoomMin']), hi = asInt(m['zoomMax']); + if (f == null || lo == null || hi == null || lo <= 0 || hi <= lo) return null; + return ZoomInfo(focalLength: f, min: lo, max: hi, opticalMax: asInt(m['zoomOpticalMax']), step: asInt(m['zoomStep'])); + } + + final int focalLength; + final int min; + final int max; + final int? opticalMax; + final int? step; + + double get factor => focalLength / min; + double get maxFactor => max / min; + + /// Past the optical range on a hybrid camera (the image is cropped). + bool get digital => opticalMax != null && focalLength > opticalMax!; + + /// The next focal length one T / W press lands on: an eighth of the range, + /// snapped to the camera's step. + int stepped(bool zoomIn) { + final int s = (step ?? 1) <= 0 ? 1 : step!; + final int raw = ((max - min) / 8).round(); + final int delta = raw < s ? s : (raw ~/ s) * s; + return (focalLength + (zoomIn ? delta : -delta)).clamp(min, max); + } +} + /// Camera state from the bridge: recording, exposure read-back, storage and /// the watched camera settings. Recording and exposure arrive as their own /// `camera` / `exposure` events; storage and settings as the `storage` and @@ -115,6 +149,19 @@ class CameraState extends ChangeNotifier { bool isRecording = false; int recordSeconds = 0; + // Shooting flags from the system state. + bool shootingSingle = false; + bool shootingInterval = false; + bool shootingBurst = false; + bool shootingPanorama = false; + int panoramaProgress = 0; // 0..100 + bool storingPhoto = false; + bool overheating = false; + bool hasError = false; + + /// Any multi-shot capture the shutter button should stop rather than start. + bool get shootingContinuous => shootingInterval || shootingPanorama || shootingBurst; + /// `SHOOT_PHOTO` / `RECORD_VIDEO` / `PLAYBACK` / `MEDIA_DOWNLOAD`. String? mode; @@ -132,14 +179,55 @@ class CameraState extends ChangeNotifier { /// Spot-meter temperature in °C, only from thermal cameras. double? thermalSpot; - bool? aeLock; - String? focusMode; // MANUAL / AUTO / AFC - String? photoAspectRatio; // RATIO_4_3 / RATIO_16_9 / RATIO_3_2 - String? photoFileFormat; // JPEG / RAW / RAW_AND_JPEG - String? videoResolution; // RESOLUTION_3840x2160 … - String? videoFrameRate; // FRAME_RATE_29_DOT_970_FPS … - String? videoFileFormat; // MOV / MP4 - String? colorProfile; // NONE / D_LOG / … + // The watched settings, as the HUD shows them (pending writes included). + bool? get aeLock => asBool(settings['aeLock']); + String? get focusMode => asString(settings['focusMode']); // MANUAL / AUTO / AFC + String? get photoAspectRatio => asString(settings['photoAspectRatio']); // RATIO_4_3 / RATIO_16_9 / RATIO_3_2 + String? get photoFileFormat => asString(settings['photoFileFormat']); // JPEG / RAW / RAW_AND_JPEG + String? get videoResolution => asString(settings['videoResolution']); // RESOLUTION_3840x2160 … + String? get videoFrameRate => asString(settings['videoFrameRate']); // FRAME_RATE_29_DOT_970_FPS … + String? get videoFileFormat => asString(settings['videoFileFormat']); // MOV / MP4 + String? get colorProfile => asString(settings['colorProfile']); // NONE / D_LOG / … + + /// The whole `cameraSettings` group — every id of `CameraBridge.kt`'s + /// settings table plus the composite values — as the camera reported it, + /// overlaid with the camera menu's pending writes. The menu's rows read it + /// through [setting]. + Map get settings => _pending.isEmpty ? _reported : {..._reported, ..._pending}; + + /// Only what the camera itself reported. + Map get reportedSettings => _reported; + + Map _reported = const {}; + + /// Values written ahead of the camera's read-back. Each one stands until the + /// camera reports that setting changed, the write fails, or the menu gives up + /// waiting ([dropPending]) — so a value the camera silently refused snaps back. + final Map _pending = {}; + + bool isPending(String id) => _pending.containsKey(id); + + Object? setting(String id) => settings[id]; + + /// `PROGRAM` / `SHUTTER_PRIORITY` / `APERTURE_PRIORITY` / `MANUAL`. + String? get exposureMode => asString(settings['exposureMode']); + + /// The ISO setting (`AUTO` included); [iso] is what the camera is using now. + String? get isoSetting => asString(settings['isoSetting']); + String? get whiteBalancePreset => asString(settings['whiteBalancePreset']); + int? get whiteBalanceKelvin => asInt(settings['whiteBalanceKelvin']); + String? get shootPhotoMode => asString(settings['shootPhotoMode']); + String? get meteringMode => asString(settings['meteringMode']); + int? get intervalSeconds => asInt(settings['intervalSeconds']); + int? get intervalCount => asInt(settings['intervalCount']); + bool get histogramEnabled => asBool(settings['histogramEnabled']) ?? false; + + /// Zoom, in the camera's own focal-length units; null on cameras without it. + ZoomInfo? get zoom => ZoomInfo.fromSettings(settings); + + /// Luminance histogram bins while [histogramEnabled]; a separate notifier + /// because frames arrive at 4 Hz and only the histogram widget redraws. + final ValueNotifier?> histogram = ValueNotifier?>(null); DateTime? updatedAt; @@ -147,9 +235,38 @@ class CameraState extends ChangeNotifier { isRecording = asBool(e['isRecording']) ?? false; recordSeconds = asInt(e['recordingTimeSeconds']) ?? recordSeconds; mode = asString(e['mode']) ?? mode; + shootingSingle = asBool(e['shootingSingle']) ?? false; + shootingInterval = asBool(e['shootingInterval']) ?? false; + shootingBurst = asBool(e['shootingBurst']) ?? false; + shootingPanorama = asBool(e['shootingPanorama']) ?? false; + panoramaProgress = asInt(e['panoramaProgress']) ?? 0; + storingPhoto = asBool(e['storingPhoto']) ?? false; + overheating = asBool(e['overheating']) ?? false; + hasError = asBool(e['hasError']) ?? false; notifyListeners(); } + void applyHistogram(Map e) { + final Object? bins = e['bins']; + histogram.value = bins is List ? [for (final Object? b in bins) asInt(b) ?? 0] : null; + } + + /// Shows [values] ahead of the camera's read-back (the camera menu's + /// optimistic write). + void setPending(Map values) { + _pending.addAll(values); + notifyListeners(); + } + + /// Rolls [ids] back to what the camera last reported. + void dropPending(Iterable ids) { + bool changed = false; + for (final String id in ids) { + changed = _pending.remove(id) != null || changed; + } + if (changed) notifyListeners(); + } + /// Sets [mode] ahead of the camera's own `camera` event, so the HUD follows /// the photo / video switch at once. void setModeOptimistic(String mode) { @@ -178,14 +295,10 @@ class CameraState extends ChangeNotifier { this.storage = storage == null ? null : StorageInfo.fromMap(storage); this.ssd = ssd == null ? null : SsdInfo.fromMap(ssd); thermalSpot = finite(thermal?['spotTemperature']); - aeLock = asBool(settings?['aeLock']); - focusMode = asString(settings?['focusMode']); - photoAspectRatio = asString(settings?['photoAspectRatio']); - photoFileFormat = asString(settings?['photoFileFormat']); - videoResolution = asString(settings?['videoResolution']); - videoFrameRate = asString(settings?['videoFrameRate']); - videoFileFormat = asString(settings?['videoFileFormat']); - colorProfile = asString(settings?['colorProfile']); + final Map reported = settings ?? const {}; + // The camera answered a pending write once that setting moves. + _pending.removeWhere((String id, _) => !deepEquals(_reported[id], reported[id])); + _reported = reported; updatedAt = DateTime.now(); notifyListeners(); } @@ -193,12 +306,16 @@ class CameraState extends ChangeNotifier { void clear() { isRecording = false; recordSeconds = 0; + shootingSingle = shootingInterval = shootingBurst = shootingPanorama = false; + panoramaProgress = 0; + storingPhoto = overheating = hasError = false; + _reported = const {}; + _pending.clear(); + histogram.value = null; mode = iso = shutter = aperture = ev = null; storage = null; ssd = null; thermalSpot = null; - aeLock = null; - focusMode = photoAspectRatio = photoFileFormat = videoResolution = videoFrameRate = videoFileFormat = colorProfile = null; updatedAt = null; notifyListeners(); } diff --git a/Fly App/lib/fly/telemetry_reducer.dart b/Fly App/lib/fly/telemetry_reducer.dart index a1319ee..e7ed743 100644 --- a/Fly App/lib/fly/telemetry_reducer.dart +++ b/Fly App/lib/fly/telemetry_reducer.dart @@ -35,6 +35,9 @@ class TelemetryReducer { case 'exposure': if (model.connected) model.camera.applyExposure(event); return true; + case 'histogram': + if (model.connected) model.camera.applyHistogram(event); + return true; case 'diagnostics': if (model.connected) model.alerts.apply(asMapList(event['items'])); return true; diff --git a/Fly App/lib/ui/fly/fly_screen.dart b/Fly App/lib/ui/fly/fly_screen.dart index 0d10e2a..554223f 100644 --- a/Fly App/lib/ui/fly/fly_screen.dart +++ b/Fly App/lib/ui/fly/fly_screen.dart @@ -8,7 +8,7 @@ import '../../fly/fly_prefs.dart'; import '../../fly/hud_features.dart'; import '../../fly/safety_gate.dart'; import '../../theme.dart'; -import '../camera_settings_page.dart'; +import '../album_page.dart'; import '../capture_modes_page.dart'; import '../settings_menu_page.dart'; import 'bottom/attitude_ball.dart'; @@ -20,9 +20,14 @@ import 'layers/video_layer.dart'; import 'left/action_rail.dart'; import 'left/gimbal_slider.dart'; import 'overlays/alert_stack.dart'; +import 'overlays/camera_touch_layer.dart'; +import 'overlays/histogram_view.dart'; import 'overlays/safety_banners.dart'; import 'panels/aircraft_status_sheet.dart'; +import 'panels/camera_menu/camera_menu_sheet.dart'; +import 'panels/camera_quick_exposure.dart'; import 'right/camera_column.dart'; +import 'right/zoom_column.dart'; import 'top/camera_info_strip.dart'; import 'top/smart_battery_bar.dart'; import 'top/status_bar.dart'; @@ -43,6 +48,10 @@ import 'top/status_bar.dart'; /// one [SafetyGate] via [FlightCommandFlow]; the error stack and low-battery /// banners sit under the top bar; the status chip opens the Aircraft Status /// sheet, which is also offered before each connection's first take-off. +/// +/// Camera (plan P4): the camera column, the zoom column on zoom cameras, the +/// quick exposure panel (from the camera info strip), the tabbed camera menu, +/// tap-to-focus / tap-to-meter on the video and the histogram. class FlyScreen extends StatefulWidget { const FlyScreen({super.key, required this.model, required this.dji}); @@ -65,7 +74,6 @@ class _FlyScreenState extends State { bool _wasConnected = false; bool _mapBig = false; - bool _grid = false; /// The picture-in-picture box, in HUD canvas pixels from the bottom-left corner. static const Size _pipSize = Size(150, 90); @@ -145,7 +153,17 @@ class _FlyScreenState extends State { // the swap moves the native video view instead of recreating it. if (!_mapBig) ...[ Positioned.fromRect(key: const ValueKey('video-slot'), rect: full, child: video), - Positioned.fill(child: VisualOverlays(grid: _grid)), + const Positioned.fill(child: VisualOverlays()), + Positioned.fill( + child: Requires( + HudFeatures.cameraControls, + child: ListenableBuilder( + listenable: _m, + builder: (BuildContext context, _) => + CameraTouchLayer(camera: _m.camera, dji: _dji, connected: _m.connected, onError: _error), + ), + ), + ), Positioned.fromRect(key: const ValueKey('map-slot'), rect: pip, child: map), ] else ...[ Positioned.fromRect(key: const ValueKey('map-slot'), rect: full, child: map), @@ -191,12 +209,32 @@ class _FlyScreenState extends State { model: _m, dji: _dji, onError: _error, - onCameraMenu: () => _push(CameraSettingsPage(model: _m, dji: _dji)), + onCameraMenu: () => showCameraMenu(context, model: _m, dji: _dji, onError: _error), + onPlayback: () => _push(AlbumPage(model: _m, dji: _dji)), onCaptureModes: () => _push(CaptureModesPage(model: _m, dji: _dji)), ), ), ), ), + // ── Zoom column (zoom cameras only) ─────────────────────────────────── + if (!_mapBig) + Positioned( + right: 68, + top: 76, + bottom: 0, + child: Center( + child: Requires( + HudFeatures.zoomColumn, + whenDisconnected: false, + child: ZoomColumn(camera: _m.camera, dji: _dji, onError: _error), + ), + ), + ), + Positioned( + right: 68, + top: StatusBar.height + SmartBatteryBar.height + 32, + child: Requires(HudFeatures.histogram, child: HistogramView(camera: _m.camera)), + ), // ── Gimbal ──────────────────────────────────────────────────────────── Positioned( left: 54, @@ -227,7 +265,14 @@ class _FlyScreenState extends State { ), ), Positioned(left: 0, right: 0, top: StatusBar.height + 3, child: SmartBatteryBar(model: _m)), - Positioned(right: 0, top: StatusBar.height + SmartBatteryBar.height + 5, child: CameraInfoStrip(camera: _m.camera)), + Positioned( + right: 0, + top: StatusBar.height + SmartBatteryBar.height + 5, + child: CameraInfoStrip( + camera: _m.camera, + onTap: () => showQuickExposure(context, model: _m, dji: _dji, onError: _error), + ), + ), // ── Bottom: attitude ball + telemetry ───────────────────────────────── Positioned( left: _pipSize.width + 10, @@ -253,11 +298,14 @@ class _FlyScreenState extends State { Positioned( left: 0, top: 60, - child: ActionRail( - model: _m, - flow: _flow, - grid: _grid, - onGrid: () => setState(() => _grid = !_grid), + child: ValueListenableBuilder( + valueListenable: FlyPrefs.grid, + builder: (BuildContext context, GridStyle g, _) => ActionRail( + model: _m, + flow: _flow, + grid: g != GridStyle.off, + onGrid: () => FlyPrefs.setGrid(g == GridStyle.off ? GridStyle.thirds : GridStyle.off), + ), ), ), if (_mapBig) Positioned(right: 70, top: 80, child: MinimapControls(controller: _minimap, trail: _trail)), diff --git a/Fly App/lib/ui/fly/layers/video_layer.dart b/Fly App/lib/ui/fly/layers/video_layer.dart index 7a1bed0..e78f156 100644 --- a/Fly App/lib/ui/fly/layers/video_layer.dart +++ b/Fly App/lib/ui/fly/layers/video_layer.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../../../fly/fly_prefs.dart'; import '../../dji_video_view.dart'; import '../../pv_icons.dart'; @@ -15,27 +16,36 @@ class VideoLayer extends StatelessWidget { connected ? const DjiVideoView() : const CustomPaint(painter: FeedPlaceholderPainter()); } -/// The visual overlays drawn straight on the video: rule-of-thirds grid and the -/// centre point. Never takes touches. +/// The visual overlays drawn straight on the video: the grid and the centre +/// point, as set in the camera menu (`FlyPrefs`). Never takes touches. class VisualOverlays extends StatelessWidget { - const VisualOverlays({super.key, required this.grid}); - - final bool grid; + const VisualOverlays({super.key}); @override Widget build(BuildContext context) { return IgnorePointer( child: Stack(fit: StackFit.expand, children: [ - if (grid) const CustomPaint(painter: GridPainter()), - const Center(child: PVIcon('crosshair', size: 30, stroke: 1.1, color: Color(0xB3FFFFFF))), + ValueListenableBuilder( + valueListenable: FlyPrefs.grid, + builder: (BuildContext context, GridStyle g, _) => + g == GridStyle.off ? const SizedBox.shrink() : CustomPaint(painter: GridPainter(diagonals: g == GridStyle.diagonals)), + ), + ValueListenableBuilder( + valueListenable: FlyPrefs.centerPoint, + builder: (BuildContext context, bool on, _) => on + ? const Center(child: PVIcon('crosshair', size: 30, stroke: 1.1, color: Color(0xB3FFFFFF))) + : const SizedBox.shrink(), + ), ]), ); } } -/// Rule-of-thirds grid. +/// Rule-of-thirds grid, optionally with the two diagonals. class GridPainter extends CustomPainter { - const GridPainter(); + const GridPainter({this.diagonals = false}); + + final bool diagonals; @override void paint(Canvas canvas, Size size) { @@ -47,10 +57,14 @@ class GridPainter extends CustomPainter { canvas.drawLine(Offset(x, 0), Offset(x, size.height), p); canvas.drawLine(Offset(0, y), Offset(size.width, y), p); } + if (diagonals) { + canvas.drawLine(Offset.zero, Offset(size.width, size.height), p); + canvas.drawLine(Offset(size.width, 0), Offset(0, size.height), p); + } } @override - bool shouldRepaint(covariant GridPainter oldDelegate) => false; + bool shouldRepaint(covariant GridPainter oldDelegate) => oldDelegate.diagonals != diagonals; } /// Painted placeholder feed: graded sky → ground, haze, a perspective grid and diff --git a/Fly App/lib/ui/fly/overlays/camera_touch_layer.dart b/Fly App/lib/ui/fly/overlays/camera_touch_layer.dart new file mode 100644 index 0000000..8ea2c27 --- /dev/null +++ b/Fly App/lib/ui/fly/overlays/camera_touch_layer.dart @@ -0,0 +1,174 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../../dji_service.dart'; +import '../../../fly/fly_prefs.dart'; +import '../../../fly/hud_features.dart'; +import '../../../fly/product_profile.dart'; +import '../../../fly/state/camera_state.dart'; +import '../../../theme.dart'; +import '../hud_kit.dart'; +import '../panels/camera_write.dart'; + +/// Tap-to-focus and tap-to-meter on the live view (plan P4). +/// +/// One tap focuses there — or, on a fixed-focus camera, spot-meters there. A +/// long press does what the camera menu's "Long press on video" says (GO 4's +/// Long Press setting; spot metering by default). Spot metering leaves an +/// "Exposure" box with a × that returns metering to centre-weighted; the box +/// stays while the camera reports SPOT metering. +/// +/// Positions are sent as 0..1 across this layer, which covers the full-bleed +/// video; with a letterboxed feed the edges are slightly off, which the +/// camera's coarse metering grid absorbs. +class CameraTouchLayer extends StatefulWidget { + const CameraTouchLayer({super.key, required this.camera, required this.dji, required this.connected, required this.onError}); + + final CameraState camera; + final DjiService dji; + final bool connected; + final void Function(String label, Object error) onError; + + @override + State createState() => _CameraTouchLayerState(); +} + +enum _Mark { focus, meter } + +class _CameraTouchLayerState extends State { + Offset? _focusAt; + Offset? _meterAt; + Timer? _focusFade; + + @override + void dispose() { + _focusFade?.cancel(); + super.dispose(); + } + + Future _act(_Mark mark, Offset local, Size size) async { + final double x = (local.dx / size.width).clamp(0.0, 1.0), y = (local.dy / size.height).clamp(0.0, 1.0); + setState(() { + if (mark == _Mark.focus) { + _focusAt = local; + _focusFade?.cancel(); + _focusFade = Timer(const Duration(milliseconds: 1500), () { + if (mounted) setState(() => _focusAt = null); + }); + } else { + _meterAt = local; + } + }); + if (!widget.connected) return; + try { + if (mark == _Mark.focus) { + await widget.dji.setFocusTarget(x, y); + } else { + await widget.dji.setSpotMeteringTarget(x, y); + } + } catch (e) { + if (mark == _Mark.meter && mounted) setState(() => _meterAt = null); + widget.onError(mark == _Mark.focus ? 'Focus' : 'Spot metering', e); + } + } + + Future _cancelMeter() async { + setState(() => _meterAt = null); + if (!widget.connected) return; + await writeCameraSetting( + widget.camera, + label: 'Metering', + pending: const {'meteringMode': 'CENTER'}, + send: () => widget.dji.setCameraValue('meteringMode', 'CENTER'), + onError: widget.onError, + ); + } + + @override + Widget build(BuildContext context) { + final ProductProfile p = ProductProfileScope.of(context); + // Disconnected, the layer previews both; connected, it follows the camera. + final bool canFocus = !p.connected || HudFeatures.tapFocus.enabledFor(p); + final bool canMeter = !p.connected || HudFeatures.spotMetering.enabledFor(p); + if (!canFocus && !canMeter) return const SizedBox.shrink(); + final _Mark tap = canFocus ? _Mark.focus : _Mark.meter; + + return LayoutBuilder(builder: (BuildContext context, BoxConstraints c) { + final Size size = c.biggest; + return ValueListenableBuilder( + valueListenable: FlyPrefs.longPress, + builder: (BuildContext context, LongPressAction long, _) { + final _Mark? longMark = switch (long) { + LongPressAction.meter when canMeter => _Mark.meter, + LongPressAction.focus when canFocus => _Mark.focus, + _ => null, + }; + return GestureDetector( + key: const ValueKey('camera-touch'), + behavior: HitTestBehavior.translucent, + onTapUp: (TapUpDetails d) => _act(tap, d.localPosition, size), + // Registered even when set to Nothing, so a long press never ends as a tap. + onLongPressStart: (LongPressStartDetails d) { + if (longMark != null) _act(longMark, d.localPosition, size); + }, + child: ListenableBuilder( + listenable: widget.camera, + builder: (BuildContext context, _) { + // The camera left SPOT (menu, RC, reset): the box goes too. + final String? metering = widget.camera.meteringMode; + final Offset? meter = metering != null && metering != 'SPOT' && !widget.camera.isPending('meteringMode') && widget.connected + ? null + : _meterAt; + return Stack(clipBehavior: Clip.none, children: [ + if (_focusAt != null) _box(_focusAt!, 64, HudColors.ready, null), + if (meter != null) _box(meter, 76, Glass.ink, 'Exposure'), + ]); + }, + ), + ); + }, + ); + }); + } + + Widget _box(Offset at, double side, Color color, String? label) { + return Positioned( + left: at.dx - side / 2, + top: at.dy - side / 2, + width: side, + height: side, + child: Stack(clipBehavior: Clip.none, children: [ + Positioned.fill( + child: IgnorePointer( + child: DecoratedBox(decoration: BoxDecoration(border: Border.all(color: color, width: 1.5))), + ), + ), + if (label != null) ...[ + Positioned( + left: 0, + bottom: -16, + child: IgnorePointer(child: Text(label, style: HudText.label.copyWith(color: Glass.ink))), + ), + // Inside the box: a child outside its parent's bounds is not hit-tested. + Positioned( + right: 2, + top: 2, + child: GestureDetector( + key: const ValueKey('meter-cancel'), + behavior: HitTestBehavior.opaque, + onTap: _cancelMeter, + child: Container( + width: 22, + height: 22, + decoration: const BoxDecoration(color: Glass.pillStrong, shape: BoxShape.circle), + alignment: Alignment.center, + child: Text('×', style: HudText.value.copyWith(fontSize: 13)), + ), + ), + ), + ], + ]), + ); + } +} diff --git a/Fly App/lib/ui/fly/overlays/histogram_view.dart b/Fly App/lib/ui/fly/overlays/histogram_view.dart new file mode 100644 index 0000000..6a33aad --- /dev/null +++ b/Fly App/lib/ui/fly/overlays/histogram_view.dart @@ -0,0 +1,63 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../../../fly/state/camera_state.dart'; +import '../../../theme.dart'; + +/// The live luminance histogram, shown while the camera menu's Histogram row is +/// on and the camera is streaming bins (plan P4). +class HistogramView extends StatelessWidget { + const HistogramView({super.key, required this.camera}); + + final CameraState camera; + + static const Size size = Size(120, 48); + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: camera, + builder: (BuildContext context, _) { + if (!camera.histogramEnabled) return const SizedBox.shrink(); + return ValueListenableBuilder?>( + valueListenable: camera.histogram, + builder: (BuildContext context, List? bins, _) { + if (bins == null || bins.isEmpty) return const SizedBox.shrink(); + return Container( + key: const ValueKey('histogram'), + width: size.width, + height: size.height, + padding: const EdgeInsets.all(5), + decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(8)), + child: CustomPaint(painter: HistogramPainter(bins)), + ); + }, + ); + }, + ); + } +} + +class HistogramPainter extends CustomPainter { + const HistogramPainter(this.bins); + + final List bins; + + @override + void paint(Canvas canvas, Size size) { + if (bins.length < 2) return; + final int peak = math.max(1, bins.reduce(math.max)); + final Path p = Path()..moveTo(0, size.height); + for (int i = 0; i < bins.length; i++) { + p.lineTo(size.width * i / (bins.length - 1), size.height * (1 - bins[i] / peak)); + } + p + ..lineTo(size.width, size.height) + ..close(); + canvas.drawPath(p, Paint()..color = const Color(0x99EAF0FA)); + } + + @override + bool shouldRepaint(covariant HistogramPainter old) => !identical(old.bins, bins); +} diff --git a/Fly App/lib/ui/fly/panels/camera_menu/camera_menu_sheet.dart b/Fly App/lib/ui/fly/panels/camera_menu/camera_menu_sheet.dart new file mode 100644 index 0000000..3f0611d --- /dev/null +++ b/Fly App/lib/ui/fly/panels/camera_menu/camera_menu_sheet.dart @@ -0,0 +1,173 @@ +import 'package:flutter/material.dart'; + +import '../../../../dji_service.dart'; +import '../../../../flight_model.dart'; +import '../../../../fly/camera_menu.dart'; +import '../../../../fly/fly_prefs.dart'; +import '../../../../fly/hud_features.dart'; +import '../../../../fly/product_profile.dart'; +import '../../../../theme.dart'; +import '../../../pv_icons.dart'; +import '../../hud_kit.dart'; +import '../camera_quick_exposure.dart'; +import 'camera_rows.dart'; + +/// Opens the camera menu (plan P4) as a right-side glass sheet over the live +/// view, like GO 4: tabs Exposure / Photo / Video / Other. +Future showCameraMenu( + BuildContext context, { + required FlightModel model, + required DjiService dji, + required void Function(String label, Object error) onError, + CameraMenuTab initialTab = CameraMenuTab.exposure, +}) { + return showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: 'Camera menu', + barrierColor: const Color(0x55050A14), + transitionDuration: const Duration(milliseconds: 200), + pageBuilder: (BuildContext context, _, __) => Align( + alignment: Alignment.centerRight, + // The dialog route sits outside the flight screen's scope; give its + // [Requires] the live profile again. + child: ListenableBuilder( + listenable: model, + builder: (BuildContext context, Widget? child) => ProductProfileScope(profile: model.profile, child: child!), + child: CameraMenuSheet(model: model, dji: dji, onError: onError, initialTab: initialTab), + ), + ), + transitionBuilder: (BuildContext context, Animation a, _, Widget child) => SlideTransition( + position: Tween(begin: const Offset(1, 0), end: Offset.zero).animate(CurvedAnimation(parent: a, curve: Curves.easeOut)), + child: child, + ), + ); +} + +class CameraMenuSheet extends StatefulWidget { + const CameraMenuSheet({ + super.key, + required this.model, + required this.dji, + required this.onError, + this.initialTab = CameraMenuTab.exposure, + }); + + final FlightModel model; + final DjiService dji; + final void Function(String label, Object error) onError; + final CameraMenuTab initialTab; + + static const double width = 380; + + @override + State createState() => _CameraMenuSheetState(); +} + +class _CameraMenuSheetState extends State { + late CameraMenuTab _tab = widget.initialTab; + + @override + Widget build(BuildContext context) { + final double height = MediaQuery.sizeOf(context).height; + final ProductProfile profile = ProductProfileScope.of(context); + return Material( + color: const Color(0xF20B1426), + child: SizedBox( + width: CameraMenuSheet.width, + height: height, + child: SafeArea( + left: false, + child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + Padding( + padding: const EdgeInsets.fromLTRB(10, 6, 4, 6), + child: Row(children: [ + for (final CameraMenuTab t in CameraMenuTab.values) Expanded(child: _tabButton(t)), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const PVIcon('close', size: 18, color: HudText.dim), + ), + ]), + ), + const Divider(height: 1, color: Glass.hairline), + Expanded( + child: ListenableBuilder( + listenable: widget.model.camera, + builder: (BuildContext context, _) => ListView( + key: ValueKey('camera-menu-${_tab.name}'), + padding: const EdgeInsets.only(bottom: 16), + children: [ + if (_tab == CameraMenuTab.exposure) + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: QuickExposurePanel(camera: widget.model.camera, dji: widget.dji, onError: widget.onError), + ), + for (final CameraRow r in CameraMenu.rowsFor(_tab, profile, widget.model.camera)) + CameraRowTile(row: r, profile: profile, camera: widget.model.camera, dji: widget.dji, onError: widget.onError), + if (_tab == CameraMenuTab.other) ..._displayRows(), + ], + ), + ), + ), + ]), + ), + ), + ); + } + + /// Device-local display settings; not camera keys, so not in [CameraMenu]. + List _displayRows() => [ + const Padding( + padding: EdgeInsets.fromLTRB(16, 14, 16, 4), + child: Text('DISPLAY', style: HudText.label), + ), + ValueListenableBuilder( + valueListenable: FlyPrefs.grid, + builder: (BuildContext context, GridStyle g, _) => ChoiceTile( + key: const ValueKey('row-grid'), + label: 'Grid', + options: <(String, String)>[for (final GridStyle s in GridStyle.values) (s.name, s.label)], + selected: g.name, + onSelect: (String v) => FlyPrefs.setGrid(GridStyle.values.byName(v)), + ), + ), + ValueListenableBuilder( + valueListenable: FlyPrefs.centerPoint, + builder: (BuildContext context, bool on, _) => + PrefSwitchTile(key: const ValueKey('row-centerPoint'), label: 'Centre point', value: on, onChanged: FlyPrefs.setCenterPoint), + ), + Requires( + HudFeatures.spotMetering, + child: ValueListenableBuilder( + valueListenable: FlyPrefs.longPress, + builder: (BuildContext context, LongPressAction a, _) => ChoiceTile( + key: const ValueKey('row-longPress'), + label: 'Long press on video', + options: <(String, String)>[for (final LongPressAction x in LongPressAction.values) (x.name, x.label)], + selected: a.name, + onSelect: (String v) => FlyPrefs.setLongPress(LongPressAction.values.byName(v)), + ), + ), + ), + ]; + + Widget _tabButton(CameraMenuTab t) { + final bool on = t == _tab; + return GestureDetector( + key: ValueKey('tab-${t.name}'), + behavior: HitTestBehavior.opaque, + onTap: () => setState(() => _tab = t), + child: Container( + height: 30, + margin: const EdgeInsets.only(right: 4), + padding: const EdgeInsets.symmetric(horizontal: 6), + decoration: BoxDecoration(color: on ? Glass.accent : Colors.transparent, borderRadius: BorderRadius.circular(7)), + alignment: Alignment.center, + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text(t.label, style: HudText.sans.copyWith(fontWeight: FontWeight.w700, color: on ? Colors.white : Glass.ink)), + ), + ), + ); + } +} diff --git a/Fly App/lib/ui/fly/panels/camera_menu/camera_rows.dart b/Fly App/lib/ui/fly/panels/camera_menu/camera_rows.dart new file mode 100644 index 0000000..6766114 --- /dev/null +++ b/Fly App/lib/ui/fly/panels/camera_menu/camera_rows.dart @@ -0,0 +1,358 @@ +import 'package:flutter/material.dart'; + +import '../../../../dji_service.dart'; +import '../../../../fly/camera_menu.dart'; +import '../../../../fly/product_profile.dart'; +import '../../../../fly/state/camera_state.dart'; +import '../../../../theme.dart'; +import '../../dialogs/slide_to_confirm.dart'; +import '../../hud_kit.dart'; +import '../camera_write.dart'; + +/// Renders one [CameraRow] spec (plan P4's row kit: choice, switch, stepper, +/// action). Writes go through [writeCameraSetting], so every row shows its +/// pending value at once and falls back to what the camera reports. +class CameraRowTile extends StatelessWidget { + const CameraRowTile({ + super.key, + required this.row, + required this.profile, + required this.camera, + required this.dji, + required this.onError, + }); + + final CameraRow row; + final ProductProfile profile; + final CameraState camera; + final DjiService dji; + final void Function(String label, Object error) onError; + + @override + Widget build(BuildContext context) { + final CameraRow r = row; + return switch (r) { + ChoiceRow() => ChoiceTile( + key: ValueKey('row-${r.id}'), + label: r.label, + options: <(String, String)>[for (final CameraOption o in r.options(profile, camera)) (o.value, o.label)], + selected: r.current(camera), + pending: r.writes.any(camera.isPending), + onSelect: (String v) => writeCameraSetting( + camera, + label: r.label, + pending: r.pending(v), + send: () => r.apply(dji, camera, v), + onError: onError, + ), + ), + SwitchRow() => _SwitchTile( + key: ValueKey('row-${r.id}'), + label: r.label, + value: r.current(camera), + onChanged: (bool on) => writeCameraSetting( + camera, + label: r.label, + pending: {r.id: on}, + send: () => r.apply(dji, on), + onError: onError, + ), + ), + StepperRow() => _StepperTile( + key: ValueKey('row-${r.id}'), + row: r, + value: r.current(camera), + onChanged: (int v) => writeCameraSetting( + camera, + label: r.label, + pending: {r.id: v}, + send: () => r.apply(dji, camera, v), + onError: onError, + ), + ), + ActionRow() => _ActionTile(key: ValueKey('row-${r.id}'), row: r, camera: camera, dji: dji, onError: onError), + }; + } +} + +/// A row that shows its current choice and expands into chips. Also used for +/// the menu's device-local display settings. +class ChoiceTile extends StatefulWidget { + const ChoiceTile({ + super.key, + required this.label, + required this.options, + required this.selected, + required this.onSelect, + this.pending = false, + }); + + final String label; + + /// (value, label) pairs. + final List<(String, String)> options; + final String? selected; + final ValueChanged onSelect; + + /// A write is waiting for the camera's answer. + final bool pending; + + @override + State createState() => _ChoiceTileState(); +} + +class _ChoiceTileState extends State { + bool _open = false; + + @override + Widget build(BuildContext context) { + String current = '—'; + for (final (String v, String l) in widget.options) { + if (v == widget.selected) current = l; + } + return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + _RowFrame( + label: widget.label, + onTap: widget.options.isEmpty ? null : () => setState(() => _open = !_open), + trailing: Row(mainAxisSize: MainAxisSize.min, children: [ + if (widget.pending) + const Padding( + padding: EdgeInsets.only(right: 6), + child: SizedBox(width: 10, height: 10, child: CircularProgressIndicator(strokeWidth: 1.5, color: HudText.dim)), + ), + Text(current, style: HudText.mono.copyWith(color: Glass.accent, fontWeight: FontWeight.w700)), + const SizedBox(width: 4), + _Chevron(open: _open), + ]), + ), + if (_open) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 10), + child: Wrap(spacing: 6, runSpacing: 6, children: [ + for (final (String v, String l) in widget.options) + GestureDetector( + key: ValueKey('opt-$v'), + onTap: () { + setState(() => _open = false); + if (v != widget.selected) widget.onSelect(v); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: v == widget.selected ? Glass.accent : Glass.pill, + borderRadius: BorderRadius.circular(7), + border: Border.all(color: v == widget.selected ? Glass.accent : Glass.hairline), + ), + child: Text(l, style: HudText.sans), + ), + ), + ]), + ), + ]); + } +} + +class _Chevron extends StatelessWidget { + const _Chevron({required this.open}); + final bool open; + + @override + Widget build(BuildContext context) => CustomPaint(size: const Size(10, 10), painter: _ChevronPainter(open)); +} + +class _ChevronPainter extends CustomPainter { + const _ChevronPainter(this.open); + final bool open; + + @override + void paint(Canvas canvas, Size size) { + final Path p = open + ? (Path() + ..moveTo(1, 7) + ..lineTo(5, 3) + ..lineTo(9, 7)) + : (Path() + ..moveTo(1, 3) + ..lineTo(5, 7) + ..lineTo(9, 3)); + canvas.drawPath( + p, + Paint() + ..color = HudText.dim + ..style = PaintingStyle.stroke + ..strokeWidth = 1.4, + ); + } + + @override + bool shouldRepaint(covariant _ChevronPainter old) => old.open != open; +} + +class _RowFrame extends StatelessWidget { + const _RowFrame({required this.label, required this.trailing, this.onTap, this.hint}); + + final String label; + final Widget trailing; + final VoidCallback? onTap; + final String? hint; + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 44), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: Row(children: [ + Expanded( + child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(label, style: HudText.sans.copyWith(fontSize: 13)), + if (hint != null) ...[ + const SizedBox(height: 2), + Text(hint!, style: HudText.label), + ], + ]), + ), + trailing, + ]), + ), + ), + ); + } +} + +class _SwitchTile extends StatelessWidget { + const _SwitchTile({super.key, required this.label, required this.value, required this.onChanged}); + + final String label; + final bool? value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) => _RowFrame( + label: label, + onTap: () => onChanged(!(value ?? false)), + trailing: SizedBox( + height: 24, + child: Switch( + value: value ?? false, + onChanged: onChanged, + activeThumbColor: Colors.white, + activeTrackColor: Glass.accent, + inactiveThumbColor: HudText.dim, + inactiveTrackColor: HudColors.off, + ), + ), + ); +} + +/// A device-local on/off (display settings), with the same look as camera rows. +class PrefSwitchTile extends StatelessWidget { + const PrefSwitchTile({super.key, required this.label, required this.value, required this.onChanged}); + + final String label; + final bool value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) => _SwitchTile(label: label, value: value, onChanged: onChanged); +} + +class _StepperTile extends StatelessWidget { + const _StepperTile({super.key, required this.row, required this.value, required this.onChanged}); + + final StepperRow row; + final int? value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final int? v = value; + Widget step(String label, int delta) { + final bool enabled = v != null && (v + delta) >= row.min && (v + delta) <= row.max; + return Opacity( + opacity: enabled ? 1 : 0.35, + child: GestureDetector( + key: ValueKey('step-${row.id}-${delta > 0 ? 'plus' : 'minus'}'), + behavior: HitTestBehavior.opaque, + onTap: enabled ? () => onChanged(v + delta) : null, + child: Container( + width: 30, + height: 28, + decoration: + BoxDecoration(color: Glass.pillStrong, borderRadius: BorderRadius.circular(7), border: Border.all(color: Glass.hairline)), + alignment: Alignment.center, + child: Text(label, style: HudText.value), + ), + ), + ); + } + + final String shown = v == null ? '—' : '${v > 0 && row.min < 0 ? '+' : ''}$v${row.unit.isEmpty ? '' : ' ${row.unit}'}'; + return _RowFrame( + label: row.label, + trailing: Row(mainAxisSize: MainAxisSize.min, children: [ + step('−', -row.step), + SizedBox( + width: 66, + child: Text(shown, textAlign: TextAlign.center, style: HudText.mono.copyWith(color: Glass.accent, fontWeight: FontWeight.w700)), + ), + step('+', row.step), + ]), + ); + } +} + +class _ActionTile extends StatelessWidget { + const _ActionTile({super.key, required this.row, required this.camera, required this.dji, required this.onError}); + + final ActionRow row; + final CameraState camera; + final DjiService dji; + final void Function(String label, Object error) onError; + + Future _run(BuildContext context) async { + if (row.confirmTitle != null) { + final SlideResult? ok = await showSlideToConfirm( + context, + SlideConfirmSpec( + icon: row.danger ? 'sdcard' : 'aperture', + title: row.confirmTitle!, + description: row.confirmText ?? '', + sliderLabel: row.danger ? 'Slide to erase' : 'Slide to confirm', + color: row.danger ? HudColors.danger : Glass.accent, + ), + ); + if (ok == null) return; + } + try { + await row.run(dji); + } catch (e) { + onError(row.label, e); + } + } + + @override + Widget build(BuildContext context) { + final String? blocked = row.blockedReason(camera); + final Color color = row.danger ? HudColors.danger : Glass.accent; + return _RowFrame( + label: row.label, + hint: blocked, + trailing: Opacity( + opacity: blocked == null ? 1 : 0.35, + child: GestureDetector( + key: ValueKey('action-${row.id}'), + onTap: blocked == null ? () => _run(context) : null, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration(borderRadius: BorderRadius.circular(7), border: Border.all(color: color)), + child: Text(row.button, style: HudText.sans.copyWith(color: color)), + ), + ), + ), + ); + } +} diff --git a/Fly App/lib/ui/fly/panels/camera_quick_exposure.dart b/Fly App/lib/ui/fly/panels/camera_quick_exposure.dart new file mode 100644 index 0000000..11ed880 --- /dev/null +++ b/Fly App/lib/ui/fly/panels/camera_quick_exposure.dart @@ -0,0 +1,489 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../../../dji_service.dart'; +import '../../../flight_model.dart'; +import '../../../fly/camera_exposure.dart'; +import '../../../fly/hud_features.dart'; +import '../../../fly/product_profile.dart'; +import '../../../fly/state/camera_state.dart'; +import '../../../theme.dart'; +import '../hud_kit.dart'; +import 'camera_write.dart'; + +/// Opens the quick exposure panel (plan P4) beside the camera column, over the +/// live view. Opened from the camera info strip; the camera menu's Exposure tab +/// embeds the same [QuickExposurePanel]. +Future showQuickExposure( + BuildContext context, { + required FlightModel model, + required DjiService dji, + required void Function(String label, Object error) onError, +}) { + return showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: 'Exposure', + barrierColor: const Color(0x33050A14), + transitionDuration: const Duration(milliseconds: 160), + pageBuilder: (BuildContext context, _, __) => SafeArea( + child: Align( + alignment: Alignment.centerRight, + child: Padding( + // Clear of the camera column, which stays usable to the right. + padding: const EdgeInsets.only(right: 76), + child: ListenableBuilder( + listenable: model, + builder: (BuildContext context, Widget? child) => ProductProfileScope(profile: model.profile, child: child!), + child: Material( + color: const Color(0xF20B1426), + borderRadius: BorderRadius.circular(14), + clipBehavior: Clip.antiAlias, + child: SizedBox( + width: QuickExposurePanel.width, + child: SingleChildScrollView( + padding: const EdgeInsets.all(12), + child: QuickExposurePanel(camera: model.camera, dji: dji, onError: onError), + ), + ), + ), + ), + ), + ), + ), + transitionBuilder: (BuildContext context, Animation a, _, Widget child) => FadeTransition(opacity: a, child: child), + ); +} + +/// Auto / A / S / M, ISO slider with Auto, shutter and aperture wheels, EV −/+ +/// over an EV strip — and in M the meter's verdict where EV would be. Every +/// control is enabled per [ExposureMode] and built from the camera's own +/// capability ranges. +class QuickExposurePanel extends StatelessWidget { + const QuickExposurePanel({super.key, required this.camera, required this.dji, required this.onError}); + + final CameraState camera; + final DjiService dji; + final void Function(String label, Object error) onError; + + static const double width = 320; + + @override + Widget build(BuildContext context) { + final CameraProfile? profile = ProductProfileScope.of(context).camera; + return ListenableBuilder( + listenable: camera, + builder: (BuildContext context, _) { + final List modes = ExposureOptions.modes(profile); + final ExposureMode mode = ExposureMode.fromWire(camera.exposureMode) ?? ExposureMode.auto; + final List isos = ExposureOptions.isos(profile); + final bool isoAuto = camera.isoSetting == 'AUTO'; + final List evs = ExposureOptions.evs(profile); + final List apertures = ExposureOptions.apertures(profile); + return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + _ModeSegments(modes: modes, selected: mode, onSelect: (ExposureMode m) => _setMode(m)), + const SizedBox(height: 12), + _Labelled( + label: 'ISO', + value: isoAuto ? 'AUTO ${camera.iso ?? ''}'.trim() : (camera.iso ?? '—'), + trailing: ExposureOptions.isoAutoAvailable(profile) && mode.isoEditable + ? _Chip(label: 'AUTO', on: isoAuto, onTap: () => _setIso(isoAuto ? (camera.iso ?? isos.first) : 'AUTO')) + : null, + child: _IsoSlider( + isos: isos, + current: camera.iso, + enabled: mode.isoEditable && !isoAuto, + onChanged: _setIso, + ), + ), + _Labelled( + label: 'SHUTTER', + value: camera.shutter ?? '—', + child: HudWheel( + key: const ValueKey('wheel-shutter'), + values: ExposureOptions.shutters(profile), + current: camera.shutter, + enabled: mode.shutterEditable, + onSelected: (String v) => _send('Shutter', () => dji.setShutterSpeed(v)), + ), + ), + if (apertures.isNotEmpty) + Requires( + HudFeatures.apertureReadout, + child: _Labelled( + label: 'APERTURE', + value: camera.aperture ?? '—', + child: HudWheel( + key: const ValueKey('wheel-aperture'), + values: apertures, + current: camera.aperture, + enabled: mode.apertureEditable, + onSelected: (String v) => _send('Aperture', () => dji.setAperture(v)), + ), + ), + ), + _Labelled( + label: mode.evEditable ? 'EV' : 'METERED', + value: camera.ev ?? '—', + valueColor: mode.evEditable ? null : _verdictColor(meterVerdict(camera)), + child: Row(children: [ + _StepButton( + key: const ValueKey('ev-minus'), + label: '−', + onTap: mode.evEditable ? () => _stepEv(evs, -1) : null, + ), + const SizedBox(width: 6), + Expanded( + child: SizedBox( + height: 26, + child: CustomPaint(painter: EvStripPainter(evs: evs, current: ExposureOptions.evValue(camera.ev), metered: !mode.evEditable)), + ), + ), + const SizedBox(width: 6), + _StepButton( + key: const ValueKey('ev-plus'), + label: '+', + onTap: mode.evEditable ? () => _stepEv(evs, 1) : null, + ), + ]), + ), + ]); + }, + ); + } + + Future _send(String label, Future Function() call) async { + try { + await call(); + } catch (e) { + onError(label, e); + } + } + + Future _setMode(ExposureMode m) => writeCameraSetting( + camera, + label: 'Exposure mode', + pending: {'exposureMode': m.wire}, + send: () => dji.setCameraValue('exposureMode', m.wire), + onError: onError, + ); + + Future _setIso(String v) => writeCameraSetting( + camera, + label: 'ISO', + pending: {'isoSetting': v}, + send: () => dji.setISO(v), + onError: onError, + ); + + Future _stepEv(List evs, int direction) async { + final String? next = ExposureOptions.stepEv(camera.ev, evs, direction); + if (next == null) return; + await _send('EV', () => dji.setEV(next)); + } + + static Color _verdictColor(MeterVerdict v) => switch (v) { + MeterVerdict.ok => HudColors.ready, + MeterVerdict.under || MeterVerdict.over => HudColors.caution, + }; +} + +class _ModeSegments extends StatelessWidget { + const _ModeSegments({required this.modes, required this.selected, required this.onSelect}); + + final List modes; + final ExposureMode selected; + final ValueChanged onSelect; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(10)), + child: Row(children: [ + for (final ExposureMode m in modes) + Expanded( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => onSelect(m), + child: Container( + key: ValueKey('exposure-${m.name}'), + height: 30, + decoration: BoxDecoration(color: m == selected ? Glass.accent : Colors.transparent, borderRadius: BorderRadius.circular(7)), + alignment: Alignment.center, + child: Text(m.label, style: HudText.sans.copyWith(fontWeight: FontWeight.w700)), + ), + ), + ), + ]), + ); + } +} + +/// A control with its label and current value above it. +class _Labelled extends StatelessWidget { + const _Labelled({required this.label, required this.value, required this.child, this.trailing, this.valueColor}); + + final String label; + final String value; + final Widget child; + final Widget? trailing; + final Color? valueColor; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ + Row(children: [ + Text(label, style: HudText.label), + const SizedBox(width: 8), + Text(value, style: HudText.mono.copyWith(fontWeight: FontWeight.w700, color: valueColor)), + const Spacer(), + if (trailing != null) trailing!, + ]), + const SizedBox(height: 4), + child, + ]), + ); + } +} + +class _Chip extends StatelessWidget { + const _Chip({required this.label, required this.on, required this.onTap}); + + final String label; + final bool on; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) => GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: on ? Glass.accent : Colors.transparent, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: on ? Glass.accent : Glass.hairline), + ), + child: Text(label, style: HudText.monoSmall.copyWith(fontWeight: FontWeight.w700)), + ), + ); +} + +class _StepButton extends StatelessWidget { + const _StepButton({super.key, required this.label, required this.onTap}); + + final String label; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) => Opacity( + opacity: onTap == null ? 0.35 : 1, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Container( + width: 34, + height: 30, + decoration: BoxDecoration(color: Glass.pillStrong, borderRadius: BorderRadius.circular(8), border: Border.all(color: Glass.hairline)), + alignment: Alignment.center, + child: Text(label, style: HudText.value), + ), + ), + ); +} + +/// ISO as a stepped slider over the camera's numeric ISOs; sends on release. +class _IsoSlider extends StatefulWidget { + const _IsoSlider({required this.isos, required this.current, required this.enabled, required this.onChanged}); + + final List isos; + final String? current; + final bool enabled; + final ValueChanged onChanged; + + @override + State<_IsoSlider> createState() => _IsoSliderState(); +} + +class _IsoSliderState extends State<_IsoSlider> { + double? _drag; + + @override + Widget build(BuildContext context) { + final int n = widget.isos.length; + final double value = _drag ?? ExposureOptions.nearest(widget.isos, widget.current).toDouble(); + return SliderTheme( + data: SliderTheme.of(context).copyWith( + trackHeight: 3, + activeTrackColor: Glass.accent, + inactiveTrackColor: HudColors.off, + thumbColor: Colors.white, + overlayShape: SliderComponentShape.noOverlay, + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8), + ), + child: SizedBox( + height: 24, + child: Slider( + key: const ValueKey('iso-slider'), + value: value.clamp(0, math.max(0, n - 1)).toDouble(), + max: math.max(1, n - 1).toDouble(), + divisions: math.max(1, n - 1), + onChanged: widget.enabled && n > 1 ? (double v) => setState(() => _drag = v) : null, + onChangeEnd: widget.enabled && n > 1 + ? (double v) { + setState(() => _drag = null); + widget.onChanged(widget.isos[v.round().clamp(0, n - 1)]); + } + : null, + ), + ), + ); + } +} + +/// A horizontal value wheel (GO 4's shutter / aperture dial): a rotated +/// [ListWheelScrollView]. It follows the camera's value while untouched and +/// reports the pilot's choice once a drag settles. +class HudWheel extends StatefulWidget { + const HudWheel({ + super.key, + required this.values, + required this.current, + required this.onSelected, + this.enabled = true, + this.itemWidth = 58, + this.height = 34, + }); + + final List values; + final String? current; + final ValueChanged onSelected; + final bool enabled; + final double itemWidth; + final double height; + + @override + State createState() => _HudWheelState(); +} + +class _HudWheelState extends State { + late final FixedExtentScrollController _c = + FixedExtentScrollController(initialItem: ExposureOptions.nearest(widget.values, widget.current)); + bool _userDragging = false; + + @override + void didUpdateWidget(covariant HudWheel old) { + super.didUpdateWidget(old); + if (_userDragging || !_c.hasClients) return; + final int target = ExposureOptions.nearest(widget.values, widget.current); + if (target != _c.selectedItem) { + _c.animateToItem(target, duration: const Duration(milliseconds: 180), curve: Curves.easeOut); + } + } + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + bool _onScroll(ScrollNotification n) { + if (n is ScrollStartNotification && n.dragDetails != null) _userDragging = true; + if (n is ScrollEndNotification && _userDragging) { + _userDragging = false; + final int i = _c.selectedItem.clamp(0, widget.values.length - 1); + if (widget.values.isNotEmpty && widget.values[i] != widget.current) widget.onSelected(widget.values[i]); + } + return false; + } + + @override + Widget build(BuildContext context) { + return Opacity( + opacity: widget.enabled ? 1 : 0.4, + child: IgnorePointer( + ignoring: !widget.enabled, + child: Container( + height: widget.height, + decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(8)), + child: Stack(children: [ + NotificationListener( + onNotification: _onScroll, + child: RotatedBox( + quarterTurns: 3, + child: ListWheelScrollView.useDelegate( + controller: _c, + itemExtent: widget.itemWidth, + diameterRatio: 2.4, + perspective: 0.004, + physics: const FixedExtentScrollPhysics(), + childDelegate: ListWheelChildBuilderDelegate( + childCount: widget.values.length, + builder: (BuildContext context, int i) => RotatedBox( + quarterTurns: 1, + child: Center( + child: Text(widget.values[i], style: HudText.mono.copyWith(fontWeight: FontWeight.w700), maxLines: 1), + ), + ), + ), + ), + ), + ), + // The selection window. + IgnorePointer( + child: Center( + child: Container( + width: widget.itemWidth, + decoration: BoxDecoration(border: Border.all(color: Glass.accent, width: 1.5), borderRadius: BorderRadius.circular(6)), + ), + ), + ), + ]), + ), + ), + ); + } +} + +/// The EV scale: a tick per third of a stop across the camera's range, whole +/// stops taller, and a marker at the current (or, in M, metered) value. +class EvStripPainter extends CustomPainter { + const EvStripPainter({required this.evs, required this.current, this.metered = false}); + + final List evs; + final double? current; + final bool metered; + + @override + void paint(Canvas canvas, Size size) { + final List vs = [for (final String e in evs) ExposureOptions.evValue(e) ?? 0]; + final double lo = vs.isEmpty ? -3 : vs.reduce(math.min), hi = vs.isEmpty ? 3 : vs.reduce(math.max); + if (hi <= lo) return; + double x(double v) => 6 + (v - lo) / (hi - lo) * (size.width - 12); + final Paint tick = Paint() + ..color = HudText.dim + ..strokeWidth = 1; + final double base = size.height - 4; + for (final double v in vs) { + final bool whole = (v - v.roundToDouble()).abs() < 0.05; + canvas.drawLine(Offset(x(v), base), Offset(x(v), base - (whole ? 9 : 4)), tick..color = v.abs() < 0.05 ? Glass.ink : HudText.dim); + } + final double? c = current; + if (c == null) return; + final double cx = x(c.clamp(lo, hi)); + final Color color = metered ? (c.abs() < 0.7 ? HudColors.ready : HudColors.caution) : Glass.accent; + // Space Mono has no ▼ glyph, so the marker is painted. + final Path marker = Path() + ..moveTo(cx - 5, 2) + ..lineTo(cx + 5, 2) + ..lineTo(cx, 9) + ..close(); + canvas.drawPath(marker, Paint()..color = color); + } + + @override + bool shouldRepaint(covariant EvStripPainter old) => old.current != current || old.metered != metered || old.evs != evs; +} diff --git a/Fly App/lib/ui/fly/panels/camera_write.dart b/Fly App/lib/ui/fly/panels/camera_write.dart new file mode 100644 index 0000000..34b2663 --- /dev/null +++ b/Fly App/lib/ui/fly/panels/camera_write.dart @@ -0,0 +1,30 @@ +import 'dart:async'; + +import '../../../fly/state/camera_state.dart'; + +/// How long a written camera setting may stay unconfirmed before the HUD +/// shows the camera's own value again. +const Duration cameraSettle = Duration(seconds: 3); + +/// Writes one camera setting the way every camera-menu row does (plan P4): +/// show [pending] at once, send, roll back with [onError] if the camera +/// refuses, and drop the pending value after [cameraSettle] in any case — by +/// then the camera has reported the new value (which replaces it) or ignored +/// the write (and the row snaps back to what it really is). +Future writeCameraSetting( + CameraState camera, { + required String label, + required Map pending, + required Future Function() send, + required void Function(String label, Object error) onError, +}) async { + camera.setPending(pending); + try { + await send(); + } catch (e) { + camera.dropPending(pending.keys); + onError(label, e); + return; + } + Timer(cameraSettle, () => camera.dropPending(pending.keys)); +} diff --git a/Fly App/lib/ui/fly/right/camera_column.dart b/Fly App/lib/ui/fly/right/camera_column.dart index 7ef6839..c33aa58 100644 --- a/Fly App/lib/ui/fly/right/camera_column.dart +++ b/Fly App/lib/ui/fly/right/camera_column.dart @@ -1,20 +1,30 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import '../../../dji_service.dart'; import '../../../flight_model.dart'; +import '../../../fly/camera_menu.dart'; +import '../../../fly/hud_features.dart'; import '../../../fly/state/camera_state.dart'; import '../../../theme.dart'; import '../../pv_icons.dart'; +import '../hud_kit.dart'; +import '../panels/camera_write.dart'; -/// The right camera column: camera menu · shutter / record · photo / video / -/// pano switch · capture modes. P4 rebuilds it around the quick exposure panel -/// and the tabbed camera menu. +/// The right camera column (plan P4): AE lock · photo / video switch · +/// shutter (photo, record with its timer, interval / panorama progress ring) · +/// MENU · playback and capture modes. +/// +/// The shutter follows the camera's own state: while an interval, burst or +/// panorama is running it shows a stop square and the press stops it. class CameraColumn extends StatefulWidget { const CameraColumn({ super.key, required this.model, required this.dji, required this.onCameraMenu, + required this.onPlayback, required this.onCaptureModes, required this.onError, }); @@ -22,6 +32,7 @@ class CameraColumn extends StatefulWidget { final FlightModel model; final DjiService dji; final VoidCallback onCameraMenu; + final VoidCallback onPlayback; final VoidCallback onCaptureModes; final void Function(String label, Object error) onError; @@ -48,16 +59,29 @@ class _CameraColumnState extends State { if (_video) { await _run(_cam.isRecording ? 'Stop recording' : 'Start recording', _cam.isRecording ? widget.dji.stopRecordVideo : widget.dji.startRecordVideo); + } else if (_cam.shootingContinuous) { + await _run('Stop shooting', widget.dji.stopShootPhoto); } else { await _run('Shoot photo', widget.dji.startShootPhoto); } } - Future _setMode(CaptureMode mode) async { - final String wire = mode == CaptureMode.video ? 'video' : 'photo'; - setState(() => _m.captureMode = mode); - _cam.setModeOptimistic(mode == CaptureMode.video ? 'RECORD_VIDEO' : 'SHOOT_PHOTO'); - await _run('Set camera mode', () => widget.dji.setCameraMode(wire)); + Future _setMode(bool video) async { + if (video == _video) return; + setState(() => _m.captureMode = video ? CaptureMode.video : CaptureMode.photo); + _cam.setModeOptimistic(video ? 'RECORD_VIDEO' : 'SHOOT_PHOTO'); + await _run('Set camera mode', () => widget.dji.setCameraMode(video ? 'video' : 'photo')); + } + + Future _toggleAeLock() { + final bool next = !(_cam.aeLock ?? false); + return writeCameraSetting( + _cam, + label: 'AE lock', + pending: {'aeLock': next}, + send: () => widget.dji.setCameraValue('aeLock', next), + onError: widget.onError, + ); } @override @@ -66,67 +90,229 @@ class _CameraColumnState extends State { listenable: _cam, builder: (BuildContext context, _) { final bool video = _video; - final bool recording = video && _cam.isRecording; + final bool locked = _cam.settings['aeLock'] == true; return Column(mainAxisSize: MainAxisSize.min, children: [ - GestureDetector( - onTap: widget.onCameraMenu, - child: Container( - width: 44, - height: 30, - decoration: BoxDecoration(color: Glass.pillStrong, borderRadius: BorderRadius.circular(8), border: Border.all(color: Glass.hairline)), - alignment: Alignment.center, - child: const Text('MENU', style: TextStyle(fontFamily: PV.fontMono, fontSize: 10, fontWeight: FontWeight.w700, color: Glass.ink)), - ), + _Pill( + key: const ValueKey('ae-lock'), + label: 'AE', + color: locked ? Glass.accent : Glass.pillStrong, + width: 44, + height: 26, + onTap: _toggleAeLock, ), - const SizedBox(height: 12), - GestureDetector( - onTap: _shutter, - child: Container( - width: 60, - height: 60, - decoration: BoxDecoration(shape: BoxShape.circle, border: Border.all(color: const Color(0xD9FFFFFF), width: 4)), - child: Center( - child: recording - ? Container(width: 22, height: 22, decoration: BoxDecoration(color: Glass.rec, borderRadius: BorderRadius.circular(5))) - : Container(width: 44, height: 44, decoration: BoxDecoration(color: video ? Glass.rec : Colors.white, shape: BoxShape.circle)), - ), - ), - ), - const SizedBox(height: 12), + const SizedBox(height: 8), Container( padding: const EdgeInsets.all(3), decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(10)), child: Column(children: [ - _modeBtn('image', CaptureMode.photo, active: !video && _m.captureMode != CaptureMode.pano), - _modeBtn('video', CaptureMode.video, active: video), - _modeBtn('pano', CaptureMode.pano, active: !video && _m.captureMode == CaptureMode.pano), + _modeBtn('image', active: !video, onTap: () => _setMode(false), key: const ValueKey('mode-photo')), + _modeBtn('video', active: video, onTap: () => _setMode(true), key: const ValueKey('mode-video')), ]), ), - const SizedBox(height: 12), - GestureDetector( - onTap: widget.onCaptureModes, - child: Container( - width: 40, - height: 40, - decoration: BoxDecoration(color: Glass.pillStrong, shape: BoxShape.circle, border: Border.all(color: Glass.hairline)), - alignment: Alignment.center, - child: const PVIcon('aperture', size: 18, stroke: 1.8, color: Glass.ink), - ), + const SizedBox(height: 8), + ShutterButton(camera: _cam, video: video, onTap: _shutter), + SizedBox( + height: 14, + child: Center(child: Text(_caption(video), style: HudText.label.copyWith(color: Glass.ink))), ), + const SizedBox(height: 6), + _Pill(key: const ValueKey('camera-menu'), label: 'MENU', width: 44, height: 28, onTap: widget.onCameraMenu), + const SizedBox(height: 8), + Row(mainAxisSize: MainAxisSize.min, children: [ + Requires( + HudFeatures.playback, + child: _Round(key: const ValueKey('playback'), icon: 'play', onTap: widget.onPlayback), + ), + const SizedBox(width: 4), + _Round(key: const ValueKey('capture-modes'), icon: 'aperture', onTap: widget.onCaptureModes), + ]), ]); }, ); } - Widget _modeBtn(String icon, CaptureMode mode, {required bool active}) => GestureDetector( - onTap: () => _setMode(mode), + /// Under the shutter: the record timer, or the photo mode. + String _caption(bool video) { + if (video) return _cam.isRecording ? _clock(_cam.recordSeconds) : ''; + if (_cam.shootingPanorama) return 'PANO ${_cam.panoramaProgress}%'; + final String? mode = _cam.shootPhotoMode; + if (mode == null || mode == 'SINGLE') return ''; + final String label = CameraLabels.of(mode).toUpperCase(); + return switch (mode) { + 'INTERVAL' when _cam.intervalSeconds != null => '$label ${_cam.intervalSeconds}s', + 'BURST' when _cam.setting('burstCount') is String => '$label ${CameraLabels.of(_cam.setting('burstCount')! as String)}', + 'AEB' when _cam.setting('aebCount') is String => '$label ${CameraLabels.of(_cam.setting('aebCount')! as String)}', + _ => label, + }; + } + + static String _clock(int s) => '${(s ~/ 60).toString().padLeft(2, '0')}:${(s % 60).toString().padLeft(2, '0')}'; + + Widget _modeBtn(String icon, {required bool active, required VoidCallback onTap, Key? key}) => GestureDetector( + key: key, + onTap: onTap, child: Container( width: 38, - height: 28, + height: 26, margin: const EdgeInsets.symmetric(vertical: 1), decoration: BoxDecoration(color: active ? Glass.accent : Colors.transparent, borderRadius: BorderRadius.circular(7)), alignment: Alignment.center, - child: PVIcon(icon, size: 16, stroke: 1.8, color: Glass.ink), + child: PVIcon(icon, size: 15, stroke: 1.8, color: Glass.ink), + ), + ); +} + +/// The shutter: white disc for photo, red for video, a red square while +/// recording, and a stop square inside a progress ring while an interval or +/// panorama runs (interval: one turn per interval; panorama: its progress). +class ShutterButton extends StatefulWidget { + const ShutterButton({super.key, required this.camera, required this.video, required this.onTap}); + + final CameraState camera; + final bool video; + final VoidCallback onTap; + + @override + State createState() => _ShutterButtonState(); +} + +class _ShutterButtonState extends State with SingleTickerProviderStateMixin { + late final AnimationController _interval = AnimationController(vsync: this); + + @override + void didUpdateWidget(covariant ShutterButton old) { + super.didUpdateWidget(old); + _syncInterval(); + } + + @override + void initState() { + super.initState(); + _syncInterval(); + } + + void _syncInterval() { + final CameraState c = widget.camera; + final int seconds = c.intervalSeconds ?? 0; + if (!widget.video && c.shootingInterval && seconds > 0) { + final Duration d = Duration(seconds: seconds); + if (!_interval.isAnimating || _interval.duration != d) { + _interval.duration = d; + _interval.repeat(); + } + } else if (_interval.isAnimating) { + _interval.stop(); + _interval.value = 0; + } + } + + @override + void dispose() { + _interval.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final CameraState c = widget.camera; + final bool recording = widget.video && c.isRecording; + final bool running = !widget.video && c.shootingContinuous; + final double? ring = !running + ? null + : c.shootingPanorama + ? c.panoramaProgress / 100 + : null; // interval: animated below; burst: no progress to show + Widget inner; + if (recording || running) { + inner = Container(width: 22, height: 22, decoration: BoxDecoration(color: Glass.rec, borderRadius: BorderRadius.circular(5))); + } else { + inner = Container(width: 44, height: 44, decoration: BoxDecoration(color: widget.video ? Glass.rec : Colors.white, shape: BoxShape.circle)); + } + return GestureDetector( + key: const ValueKey('shutter'), + onTap: widget.onTap, + child: SizedBox( + width: 60, + height: 60, + child: Stack(alignment: Alignment.center, children: [ + Container(decoration: BoxDecoration(shape: BoxShape.circle, border: Border.all(color: const Color(0xD9FFFFFF), width: 4))), + if (running) + AnimatedBuilder( + animation: _interval, + builder: (BuildContext context, _) => CustomPaint( + size: const Size(60, 60), + painter: _RingPainter(ring ?? (c.shootingInterval ? _interval.value : 1)), + ), + ), + inner, + ]), + ), + ); + } +} + +class _RingPainter extends CustomPainter { + const _RingPainter(this.progress); + + final double progress; + + @override + void paint(Canvas canvas, Size size) { + final Rect r = (Offset.zero & size).deflate(2); + canvas.drawArc( + r, + -math.pi / 2, + 2 * math.pi * progress.clamp(0, 1), + false, + Paint() + ..color = Glass.accent + ..style = PaintingStyle.stroke + ..strokeWidth = 4 + ..strokeCap = StrokeCap.round, + ); + } + + @override + bool shouldRepaint(covariant _RingPainter old) => old.progress != progress; +} + +class _Pill extends StatelessWidget { + const _Pill({super.key, required this.label, required this.onTap, this.color = Glass.pillStrong, this.width = 44, this.height = 28}); + + final String label; + final VoidCallback onTap; + final Color color; + final double width; + final double height; + + @override + Widget build(BuildContext context) => GestureDetector( + onTap: onTap, + child: Container( + width: width, + height: height, + decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(8), border: Border.all(color: Glass.hairline)), + alignment: Alignment.center, + child: Text(label, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 10, fontWeight: FontWeight.w700, color: Glass.ink)), + ), + ); +} + +class _Round extends StatelessWidget { + const _Round({super.key, required this.icon, required this.onTap}); + + final String icon; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) => GestureDetector( + onTap: onTap, + child: Container( + width: 30, + height: 30, + decoration: BoxDecoration(color: Glass.pillStrong, shape: BoxShape.circle, border: Border.all(color: Glass.hairline)), + alignment: Alignment.center, + child: PVIcon(icon, size: 14, stroke: 1.8, color: Glass.ink), ), ); } diff --git a/Fly App/lib/ui/fly/right/zoom_column.dart b/Fly App/lib/ui/fly/right/zoom_column.dart new file mode 100644 index 0000000..f0f7737 --- /dev/null +++ b/Fly App/lib/ui/fly/right/zoom_column.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; + +import '../../../dji_service.dart'; +import '../../../fly/hud_features.dart'; +import '../../../fly/product_profile.dart'; +import '../../../fly/state/camera_state.dart'; +import '../../../theme.dart'; +import '../hud_kit.dart'; + +/// T / W / factor / 1× for zoom cameras (plan P4, GO 4's +/// `camera_zoom_focus_view`). Gate it with [HudFeatures.zoomColumn]. +/// +/// Optical-only zoom cameras (Z30) zoom continuously while T or W is held; +/// hybrid cameras (Mavic 2 Zoom) step through the focal range — an eighth per +/// press — because the SDK's continuous zoom stops at the optical end. +class ZoomColumn extends StatelessWidget { + const ZoomColumn({super.key, required this.camera, required this.dji, required this.onError}); + + final CameraState camera; + final DjiService dji; + final void Function(String label, Object error) onError; + + Future _run(String label, Future Function() call) async { + try { + await call(); + } catch (e) { + onError(label, e); + } + } + + @override + Widget build(BuildContext context) { + final CameraProfile? c = ProductProfileScope.of(context).camera; + final bool continuous = c != null && c.opticalZoomSupported && !c.hybridZoomSupported; + return ListenableBuilder( + listenable: camera, + builder: (BuildContext context, _) { + final ZoomInfo? z = camera.zoom; + Widget button(String label, bool zoomIn) { + final Key key = ValueKey(zoomIn ? 'zoom-in' : 'zoom-out'); + if (continuous) { + return _ZoomButton( + key: key, + label: label, + onPress: () => _run('Zoom', () => dji.startContinuousZoom(zoomIn ? 'in' : 'out')), + onRelease: () => _run('Zoom', dji.stopContinuousZoom), + ); + } + return _ZoomButton( + key: key, + label: label, + onTap: z == null ? null : () => _run('Zoom', () => dji.setZoomFocalLength(z.stepped(zoomIn))), + ); + } + + return Container( + width: 40, + padding: const EdgeInsets.symmetric(vertical: 4), + decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(10)), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + button('T', true), + const SizedBox(height: 4), + Text( + z == null ? '—' : '${z.factor.toStringAsFixed(1)}×', + key: const ValueKey('zoom-factor'), + style: HudText.monoSmall.copyWith(fontWeight: FontWeight.w700, color: z?.digital == true ? HudColors.caution : Glass.ink), + ), + const SizedBox(height: 4), + button('W', false), + const SizedBox(height: 6), + _ZoomButton( + key: const ValueKey('zoom-reset'), + label: '1×', + small: true, + onTap: z == null || z.focalLength == z.min ? null : () => _run('Zoom', () => dji.setZoomFocalLength(z.min)), + ), + ]), + ); + }, + ); + } +} + +class _ZoomButton extends StatelessWidget { + const _ZoomButton({super.key, required this.label, this.onTap, this.onPress, this.onRelease, this.small = false}); + + final String label; + final VoidCallback? onTap; + final VoidCallback? onPress; + final VoidCallback? onRelease; + final bool small; + + @override + Widget build(BuildContext context) { + final bool enabled = onTap != null || onPress != null; + return Opacity( + opacity: enabled ? 1 : 0.4, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + onTapDown: onPress == null ? null : (_) => onPress!(), + onTapUp: onRelease == null ? null : (_) => onRelease!(), + onTapCancel: onRelease, + child: Container( + width: 32, + height: small ? 24 : 32, + decoration: BoxDecoration(color: Glass.pillStrong, borderRadius: BorderRadius.circular(8), border: Border.all(color: Glass.hairline)), + alignment: Alignment.center, + child: Text(label, style: (small ? HudText.monoSmall : HudText.value).copyWith(fontWeight: FontWeight.w700)), + ), + ), + ); + } +} diff --git a/Fly App/lib/ui/fly/top/camera_info_strip.dart b/Fly App/lib/ui/fly/top/camera_info_strip.dart index ac83b14..d57412b 100644 --- a/Fly App/lib/ui/fly/top/camera_info_strip.dart +++ b/Fly App/lib/ui/fly/top/camera_info_strip.dart @@ -10,10 +10,13 @@ import '../hud_kit.dart'; /// REC time or what is left to shoot · colour profile · AE lock — plus the SSD /// chip and the thermal spot reading on the cameras that have them. class CameraInfoStrip extends StatelessWidget { - const CameraInfoStrip({super.key, required this.camera}); + const CameraInfoStrip({super.key, required this.camera, this.onTap}); final CameraState camera; + /// Opens the quick exposure panel (plan P4), as tapping GO 4's parameter bar does. + final VoidCallback? onTap; + @override Widget build(BuildContext context) { return Requires( @@ -26,7 +29,9 @@ class CameraInfoStrip extends StatelessWidget { final SsdInfo? ssd = c.ssd; final String? colour = _colourLabel(c.colorProfile); return GlassPill( + key: const ValueKey('camera-info'), height: 22, + onTap: onTap, child: Row(mainAxisSize: MainAxisSize.min, children: [ _sd(sd), Requires(HudFeatures.ssdStatus, whenDisconnected: false, child: _ssd(ssd)), diff --git a/Fly App/lib/uploader.dart b/Fly App/lib/uploader.dart index 6b96479..abd8f8b 100644 --- a/Fly App/lib/uploader.dart +++ b/Fly App/lib/uploader.dart @@ -133,6 +133,8 @@ class ServerUploader { void onEvent(Map event) { final WebSocket? sock = _socket; if (sock == null || _status != UploadStatus.connected) return; + // Live-view only, 4 Hz; the server has no use for it. + if (event['type'] == 'histogram') return; if (event['type'] == 'telemetry') { final DateTime now = DateTime.now(); diff --git a/Fly App/test/fly/camera_test.dart b/Fly App/test/fly/camera_test.dart new file mode 100644 index 0000000..a7064ff --- /dev/null +++ b/Fly App/test/fly/camera_test.dart @@ -0,0 +1,422 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pilotvault_fly/flight_model.dart'; +import 'package:pilotvault_fly/fly/camera_exposure.dart'; +import 'package:pilotvault_fly/fly/camera_menu.dart'; +import 'package:pilotvault_fly/fly/fly_prefs.dart'; +import 'package:pilotvault_fly/fly/product_profile.dart'; +import 'package:pilotvault_fly/fly/state/camera_state.dart'; +import 'package:pilotvault_fly/fly/telemetry_reducer.dart'; +import 'package:pilotvault_fly/ui/fly/dialogs/slide_to_confirm.dart'; +import 'package:pilotvault_fly/ui/fly/fly_screen.dart'; +import 'package:pilotvault_fly/ui/fly/panels/camera_menu/camera_menu_sheet.dart'; +import 'package:pilotvault_fly/ui/fly/panels/camera_write.dart'; + +import 'fake_dji.dart'; +import 'profile_fixtures.dart'; +import 'telemetry_fixtures.dart'; + +/// A connected model whose camera has reported [settings] on top of the +/// fixture tick's, plus a system state and an exposure read-back. +FlightModel cameraModel( + Map profile, { + Map settings = const {}, + Map system = const {}, +}) { + final FlightModel m = FlightModel() + ..connected = true + ..profile = ProductProfile.fromBridge(profile); + final TelemetryReducer r = TelemetryReducer(m); + final Map t = telemetryTick(); + t['cameraSettings'] = { + ...(t['cameraSettings'] as Map), + 'exposureMode': 'PROGRAM', + 'isoSetting': 'AUTO', + 'shootPhotoMode': 'SINGLE', + 'meteringMode': 'CENTER', + 'whiteBalancePreset': 'AUTO', + 'histogramEnabled': false, + ...settings, + }; + r.apply(t); + r.apply({'type': 'camera', 'isRecording': false, 'mode': 'SHOOT_PHOTO', ...system}); + r.apply({'type': 'exposure', 'iso': '100', 'shutter': '1/240', 'aperture': 'f/2.8', 'ev': '0.0'}); + return m; +} + +/// Re-sends the camera's settings group, as the next telemetry tick would. +void reportSettings(FlightModel m, Map settings) { + m.camera.applyTelemetry(settings: {...m.camera.reportedSettings, ...settings}); +} + +void main() { + group('Exposure rules', () { + test('A only on adjustable-aperture lenses', () { + const List all = ['PROGRAM', 'SHUTTER_PRIORITY', 'APERTURE_PRIORITY', 'MANUAL']; + expect(ExposureOptions.modes(const CameraProfile(exposureModes: all)), + [ExposureMode.auto, ExposureMode.shutter, ExposureMode.manual]); + expect(ExposureOptions.modes(const CameraProfile(exposureModes: all, adjustableApertureSupported: true)), ExposureMode.values); + expect(ExposureOptions.modes(ProductProfile.fromBridge(phantom4).camera), [ExposureMode.auto, ExposureMode.manual], + reason: 'only what the camera reports'); + expect(ExposureOptions.modes(null), ExposureMode.values, reason: 'preview with nothing connected'); + }); + + test('what each mode lets the pilot set', () { + expect(ExposureMode.auto.isoEditable, isFalse); + expect(ExposureMode.auto.evEditable, isTrue); + expect(ExposureMode.shutter.shutterEditable, isTrue); + expect(ExposureMode.shutter.apertureEditable, isFalse); + expect(ExposureMode.aperture.apertureEditable, isTrue); + expect(ExposureMode.manual.evEditable, isFalse, reason: 'EV is the meter in M'); + expect(ExposureMode.fromWire('SHUTTER_PRIORITY'), ExposureMode.shutter); + }); + + test('option lists come from the camera, with fallbacks before the handshake', () { + final CameraProfile? c = ProductProfile.fromBridge(phantom4).camera; + expect(ExposureOptions.isos(c), ['100', '200', '400', '800', '1600'], reason: 'AUTO is its own toggle'); + expect(ExposureOptions.shutters(c), ['1/8000', '1/1000', '1/100', '1/30']); + expect(ExposureOptions.isos(const CameraProfile()), isNotEmpty); + expect(ExposureOptions.evs(c), ['-3.0', '0.0', '+3.0']); + }); + + test('EV steps to the neighbour in the camera range and stops at the ends', () { + final List evs = ExposureOptions.evs(null); + expect(ExposureOptions.stepEv('0.0', evs, 1), '+0.3'); + expect(ExposureOptions.stepEv('0.0', evs, -1), '-0.3'); + expect(ExposureOptions.stepEv('+3.0', evs, 1), isNull); + expect(ExposureOptions.stepEv(null, evs, 1), '+0.3'); + }); + + test('a wheel starts on the nearest value', () { + const List s = ['1/1000', '1/500', '1/240', '1/120']; + expect(ExposureOptions.nearest(s, '1/240'), 2); + expect(ExposureOptions.nearest(s, '1/250'), 2); + expect(ExposureOptions.nearest(const ['f/2.8', 'f/4', 'f/5.6'], 'f/4.5'), 1); + }); + + test('meter verdict', () { + final CameraState c = CameraState()..applyExposure({'ev': '-1.0'}); + expect(meterVerdict(c), MeterVerdict.under); + c.applyExposure({'ev': '+0.3'}); + expect(meterVerdict(c), MeterVerdict.ok); + }); + }); + + group('Zoom', () { + test('factor, steps and the digital range', () { + final CameraState c = CameraState() + ..applyTelemetry(settings: { + 'zoomFocalLength': 240, + 'zoomMin': 240, + 'zoomMax': 960, + 'zoomOpticalMax': 480, + 'zoomStep': 10, + }); + final ZoomInfo z = c.zoom!; + expect(z.factor, 1.0); + expect(z.maxFactor, 4.0); + expect(z.stepped(true), 330); + expect(z.stepped(false), 240, reason: 'clamped at the wide end'); + expect(z.digital, isFalse); + c.applyTelemetry(settings: {...c.reportedSettings, 'zoomFocalLength': 600}); + expect(c.zoom!.digital, isTrue); + }); + + test('no zoom without a spec', () { + expect((CameraState()..applyTelemetry(settings: {'zoomFocalLength': 240})).zoom, isNull); + }); + }); + + group('Pending camera writes', () { + test('show at once, clear when the camera reports the setting moved', () { + final FlightModel m = cameraModel(phantom4); + final CameraState c = m.camera; + c.setPending({'photoAspectRatio': 'RATIO_16_9'}); + expect(c.reportedSettings['photoAspectRatio'], 'RATIO_3_2'); + expect(c.photoAspectRatio, 'RATIO_16_9'); + expect(c.isPending('photoAspectRatio'), isTrue); + + // A tick that only moved storage leaves the pending value up. + c.applyTelemetry(storage: {'inserted': true}, settings: c.reportedSettings); + expect(c.isPending('photoAspectRatio'), isTrue); + + reportSettings(m, {'photoAspectRatio': 'RATIO_16_9'}); + expect(c.isPending('photoAspectRatio'), isFalse); + expect(c.setting('photoAspectRatio'), 'RATIO_16_9'); + }); + + test('roll back to the reported value', () { + final CameraState c = cameraModel(phantom4).camera; + c.setPending({'photoAspectRatio': 'RATIO_16_9'}); + c.dropPending(['photoAspectRatio']); + expect(c.setting('photoAspectRatio'), 'RATIO_3_2'); + }); + + test('a refused write rolls back and reports', () async { + final CameraState c = cameraModel(phantom4).camera; + final FakeDjiService dji = FakeDjiService()..failWith = PlatformException(code: 'DJI_ERROR', message: 'nope'); + String? failed; + await writeCameraSetting( + c, + label: 'Image size', + pending: {'photoAspectRatio': 'RATIO_16_9'}, + send: () => dji.setCameraValue('photoAspectRatio', 'RATIO_16_9'), + onError: (String label, _) => failed = label, + ); + expect(failed, 'Image size'); + expect(c.setting('photoAspectRatio'), 'RATIO_3_2'); + }); + }); + + group('Camera menu rows', () { + List ids(CameraMenuTab tab, FlightModel m) => + CameraMenu.rowsFor(tab, m.profile, m.camera).map((CameraRow r) => r.id).toList(); + + test('photo-mode sub-rows follow the photo mode', () { + FlightModel m = cameraModel(phantom4, settings: {'burstCount': 'BURST_COUNT_3', 'intervalSeconds': 5}); + expect(ids(CameraMenuTab.photo, m), ['shootPhotoMode', 'photoAspectRatio', 'photoFileFormat']); + m = cameraModel(phantom4, settings: {'shootPhotoMode': 'BURST', 'burstCount': 'BURST_COUNT_3'}); + expect(ids(CameraMenuTab.photo, m), contains('burstCount')); + m = cameraModel(phantom4, settings: {'shootPhotoMode': 'INTERVAL', 'intervalSeconds': 5}); + expect(ids(CameraMenuTab.photo, m), contains('intervalSeconds')); + }); + + test('a setting the camera never reported has no row', () { + final FlightModel m = cameraModel(phantom4); + expect(ids(CameraMenuTab.video, m), isNot(contains('videoCaption'))); + expect(ids(CameraMenuTab.video, cameraModel(phantom4, settings: {'videoCaption': true})), contains('videoCaption')); + }); + + test('product predicates hide rows', () { + expect(ids(CameraMenuTab.exposure, cameraModel(phantom4, settings: {'focusMode': 'AFC'})), + isNot(contains('focusMode')), reason: 'fixed-focus lens'); + expect(ids(CameraMenuTab.exposure, cameraModel(inspire2X5s)), contains('focusMode')); + }); + + test('custom white balance shows the temperature stepper', () { + expect(ids(CameraMenuTab.video, cameraModel(phantom4)), isNot(contains('whiteBalanceKelvin'))); + expect( + ids(CameraMenuTab.video, cameraModel(phantom4, settings: {'whiteBalancePreset': 'CUSTOM', 'whiteBalanceKelvin': 5600})), + contains('whiteBalanceKelvin'), + ); + }); + + test('options come from the camera range when it published one', () { + final FlightModel m = cameraModel(phantom4); + final ChoiceRow wb = CameraMenu.all.whereType().firstWhere((ChoiceRow r) => r.id == 'whiteBalancePreset'); + expect(wb.options(m.profile, m.camera).map((CameraOption o) => o.value), ['AUTO', 'SUNNY', 'CLOUDY', 'CUSTOM']); + final ChoiceRow size = CameraMenu.all.whereType().firstWhere((ChoiceRow r) => r.id == 'videoSize'); + expect(size.options(m.profile, m.camera).map((CameraOption o) => o.label), ['4K 29.97p', '1080p 59.94p']); + expect(size.current(m.camera), 'RESOLUTION_3840x2160@FRAME_RATE_29_DOT_970_FPS'); + }); + + test('labels', () { + expect(CameraLabels.of('RATIO_16_9'), '16:9'); + expect(CameraLabels.of('BURST_COUNT_7'), '7'); + expect(CameraLabels.of('FILM_A'), 'Film a'); + expect(CameraLabels.fpsLabel('FRAME_RATE_30_FPS'), '30p'); + }); + + test('Format and Reset wait for shooting to stop', () { + final ActionRow format = CameraMenu.all.whereType().firstWhere((ActionRow r) => r.id == 'formatSd'); + final FlightModel m = cameraModel(phantom4, system: {'isRecording': true}); + expect(format.blockedReason(m.camera), 'Stop shooting first'); + expect(format.blockedReason(cameraModel(phantom4).camera), isNull); + }); + }); + + group('Camera HUD', () { + Future pump(WidgetTester tester, FlightModel m, {Size size = const Size(844, 390)}) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final FakeDjiService dji = FakeDjiService(); + // A fresh navigator, so a sheet left open by an earlier pump is gone. + await tester.pumpWidget(const SizedBox()); + await tester.pumpWidget(MaterialApp(home: FlyScreen(model: m, dji: dji))); + await tester.pump(const Duration(milliseconds: 50)); + return dji; + } + + Future openMenu(WidgetTester tester, String tab) async { + await tester.tap(find.byKey(const ValueKey('camera-menu'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.byKey(ValueKey('tab-$tab'))); + await tester.pump(); + } + + testWidgets('a menu row writes, shows the pending value, then what the camera reports', (WidgetTester tester) async { + final FlightModel m = cameraModel(phantom4); + final FakeDjiService dji = await pump(tester, m); + await openMenu(tester, 'photo'); + expect(find.text('3:2'), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('row-photoAspectRatio'))); + await tester.pump(); + await tester.tap(find.byKey(const ValueKey('opt-RATIO_16_9'))); + await tester.pump(); + expect(dji.calls, contains('setCameraValue(photoAspectRatio, RATIO_16_9)')); + expect(find.text('16:9'), findsOneWidget); + // The camera never confirmed: the row returns to the real value. + await tester.pump(cameraSettle); + expect(find.text('3:2'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('the camera confirming keeps the new value', (WidgetTester tester) async { + final FlightModel m = cameraModel(phantom4); + await pump(tester, m); + await openMenu(tester, 'photo'); + await tester.tap(find.byKey(const ValueKey('row-photoFileFormat'))); + await tester.pump(); + await tester.tap(find.byKey(const ValueKey('opt-JPEG'))); + await tester.pump(); + reportSettings(m, {'photoFileFormat': 'JPEG'}); + await tester.pump(cameraSettle); + expect(find.text('JPEG'), findsOneWidget); + }); + + testWidgets('a refused write snaps back and says why', (WidgetTester tester) async { + final FlightModel m = cameraModel(phantom4); + final FakeDjiService dji = await pump(tester, m); + dji.failWith = PlatformException(code: 'DJI_ERROR', message: 'Not supported'); + await openMenu(tester, 'video'); + await tester.tap(find.byKey(const ValueKey('row-videoFileFormat'))); + await tester.pump(); + await tester.tap(find.byKey(const ValueKey('opt-MP4'))); + await tester.pump(); + await tester.pump(); + expect(find.text('MOV'), findsOneWidget); + expect(find.textContaining('Not supported'), findsOneWidget); + }); + + testWidgets('quick exposure: switching to M turns EV into the meter', (WidgetTester tester) async { + final FlightModel m = cameraModel(phantom4); + final FakeDjiService dji = await pump(tester, m); + await tester.tap(find.byKey(const ValueKey('camera-info'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('EV'), findsWidgets); + await tester.tap(find.byKey(const ValueKey('ev-plus'))); + await tester.pump(); + expect(dji.calls, contains('setEV(+3.0)'), reason: 'next step in the camera range'); + await tester.tap(find.byKey(const ValueKey('exposure-manual'))); + await tester.pump(); + expect(dji.calls, contains('setCameraValue(exposureMode, MANUAL)')); + expect(find.text('METERED'), findsOneWidget); + dji.calls.clear(); + await tester.tap(find.byKey(const ValueKey('ev-plus'))); + await tester.pump(); + expect(dji.calls, isEmpty, reason: 'EV is not settable in M'); + await tester.pump(cameraSettle); + }); + + testWidgets('no zoom column without a zoom camera; stepped zoom on hybrid', (WidgetTester tester) async { + await pump(tester, cameraModel(phantom4)); + expect(find.byKey(const ValueKey('zoom-in')), findsNothing); + + final FakeDjiService dji = await pump( + tester, + cameraModel(mavic2Zoom, settings: { + 'zoomFocalLength': 240, + 'zoomMin': 240, + 'zoomMax': 960, + 'zoomOpticalMax': 480, + 'zoomStep': 10, + }), + ); + expect(find.text('1.0×'), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('zoom-in'))); + await tester.pump(); + expect(dji.calls, contains('setZoomFocalLength(330)')); + expect(tester.takeException(), isNull); + }); + + testWidgets('tap focuses on a focusing lens, meters on a fixed one; × cancels metering', (WidgetTester tester) async { + FakeDjiService dji = await pump(tester, cameraModel(inspire2X5s)); + await tester.tapAt(const Offset(422, 195)); + await tester.pump(); + expect(dji.calls, contains('setFocusTarget(0.50, 0.50)')); + await tester.pump(const Duration(seconds: 2)); + + final FlightModel m = cameraModel(phantom4); + dji = await pump(tester, m); + await tester.tapAt(const Offset(422, 195)); + await tester.pump(); + expect(dji.calls, contains('setSpotMeteringTarget(0.50, 0.50)')); + reportSettings(m, {'meteringMode': 'SPOT'}); + await tester.pump(); + expect(find.text('Exposure'), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('meter-cancel'))); + await tester.pump(); + expect(dji.calls, contains('setCameraValue(meteringMode, CENTER)')); + expect(find.text('Exposure'), findsNothing); + await tester.pump(cameraSettle); + }); + + testWidgets('long press follows the setting', (WidgetTester tester) async { + addTearDown(() => FlyPrefs.longPress.value = LongPressAction.meter); + final FakeDjiService dji = await pump(tester, cameraModel(inspire2X5s)); + await tester.longPressAt(const Offset(422, 195)); + await tester.pump(); + expect(dji.calls, contains('setSpotMeteringTarget(0.50, 0.50)')); + FlyPrefs.longPress.value = LongPressAction.off; + await tester.pump(); + dji.calls.clear(); + await tester.longPressAt(const Offset(422, 195)); + await tester.pump(); + expect(dji.calls, isEmpty); + }); + + testWidgets('the shutter stops a running interval and shows the mode', (WidgetTester tester) async { + final FlightModel m = cameraModel(phantom4, + settings: {'shootPhotoMode': 'INTERVAL', 'intervalSeconds': 5}, system: {'shootingInterval': true}); + final FakeDjiService dji = await pump(tester, m); + expect(find.text('INTERVAL 5s'), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('shutter'))); + await tester.pump(); + expect(dji.calls, ['stopShootPhoto']); + }); + + testWidgets('AE lock toggles from the column', (WidgetTester tester) async { + final FakeDjiService dji = await pump(tester, cameraModel(phantom4)); + await tester.tap(find.byKey(const ValueKey('ae-lock'))); + await tester.pump(); + expect(dji.calls, contains('setCameraValue(aeLock, true)')); + await tester.pump(cameraSettle); + }); + + testWidgets('Format SD from the menu is slide-confirmed', (WidgetTester tester) async { + final FakeDjiService dji = await pump(tester, cameraModel(phantom4)); + await openMenu(tester, 'other'); + final Finder format = find.byKey(const ValueKey('action-formatSd')); + await tester.scrollUntilVisible(format, 60, + scrollable: find.descendant(of: find.byType(CameraMenuSheet), matching: find.byType(Scrollable))); + await tester.tap(format); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.textContaining('This cannot be undone'), findsOneWidget); + expect(dji.calls, isNot(contains('formatStorage'))); + await tester.drag(find.byKey(SlideToConfirmTrack.thumbKey), const Offset(600, 0)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + expect(dji.calls, contains('formatStorage')); + }); + + testWidgets('lays out without overflow on a small phone and a tablet, menu open', (WidgetTester tester) async { + for (final Size s in const [Size(740, 340), Size(1280, 800)]) { + await pump(tester, cameraModel(inspire2X5s), size: s); + expect(tester.takeException(), isNull, reason: '$s'); + await tester.tap(find.byKey(const ValueKey('camera-menu'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + for (final String t in ['exposure', 'photo', 'video', 'other']) { + await tester.tap(find.byKey(ValueKey('tab-$t'))); + await tester.pump(); + expect(tester.takeException(), isNull, reason: '$s $t'); + } + } + }); + }); +} diff --git a/Fly App/test/fly/fake_dji.dart b/Fly App/test/fly/fake_dji.dart new file mode 100644 index 0000000..d619152 --- /dev/null +++ b/Fly App/test/fly/fake_dji.dart @@ -0,0 +1,95 @@ +import 'package:pilotvault_fly/dji_service.dart'; + +/// Records every bridge call instead of reaching the platform channel. +class FakeDjiService extends DjiService { + final List calls = []; + Object? failWith; + + Future _record(String call) async { + calls.add(call); + if (failWith != null) throw failWith!; + } + + @override + Future takeOff() => _record('takeOff'); + @override + Future precisionTakeOff() => _record('precisionTakeOff'); + @override + Future land() => _record('land'); + @override + Future cancelLanding() => _record('cancelLanding'); + @override + Future confirmLanding() => _record('confirmLanding'); + @override + Future startGoHome() => _record('startGoHome'); + @override + Future cancelGoHome() => _record('cancelGoHome'); + @override + Future confirmSmartRTH(bool goHome) => _record('confirmSmartRTH($goHome)'); + @override + Future setGoHomeHeight(int m) => _record('setGoHomeHeight($m)'); + @override + Future setHomeLocation(double latitude, double longitude) => _record('setHomeLocation'); + @override + Future stopWaypointMission() => _record('stopWaypointMission'); + @override + Future stopActiveTrack() => _record('stopActiveTrack'); + @override + Future startCompassCalibration() => _record('startCompassCalibration'); + @override + Future formatStorage({String location = 'SDCARD'}) => _record('formatStorage'); + @override + Future getKey(DjiKey key) async => switch (key.key) { + 'MAX_FLIGHT_HEIGHT' => 120, + 'MAX_FLIGHT_RADIUS' => 500, + 'MAX_FLIGHT_RADIUS_ENABLED' => true, + _ => null, + }; + + // ── Camera (plan P4) ─────────────────────────────────────────────────────── + @override + Future setCameraMode(String mode) => _record('setCameraMode($mode)'); + @override + Future startShootPhoto() => _record('startShootPhoto'); + @override + Future stopShootPhoto() => _record('stopShootPhoto'); + @override + Future startRecordVideo() => _record('startRecordVideo'); + @override + Future stopRecordVideo() => _record('stopRecordVideo'); + @override + Future setCameraValue(String id, Object value) => _record('setCameraValue($id, $value)'); + @override + Future setISO(String value) => _record('setISO($value)'); + @override + Future setShutterSpeed(String value) => _record('setShutterSpeed($value)'); + @override + Future setAperture(String value) => _record('setAperture($value)'); + @override + Future setEV(String value) => _record('setEV($value)'); + @override + Future setWhiteBalance(String value) => _record('setWhiteBalance($value)'); + @override + Future setVideoResolutionAndFrameRate(String resolution, String frameRate) => + _record('setVideoResolutionAndFrameRate($resolution, $frameRate)'); + @override + Future setPhotoTimeInterval({required int count, required int seconds}) => _record('setPhotoTimeInterval($count, $seconds)'); + @override + Future setPictureStyle({required int sharpness, required int contrast, required int saturation}) => + _record('setPictureStyle($sharpness, $contrast, $saturation)'); + @override + Future setFocusTarget(double x, double y) => _record('setFocusTarget(${x.toStringAsFixed(2)}, ${y.toStringAsFixed(2)})'); + @override + Future setSpotMeteringTarget(double x, double y) => + _record('setSpotMeteringTarget(${x.toStringAsFixed(2)}, ${y.toStringAsFixed(2)})'); + @override + Future startContinuousZoom(String direction) => _record('startContinuousZoom($direction)'); + @override + Future stopContinuousZoom() => _record('stopContinuousZoom'); + @override + Future setZoomFocalLength(int value) => _record('setZoomFocalLength($value)'); + @override + Future restoreCameraDefaults() => _record('restoreCameraDefaults'); + @override + Future setHistogramEnabled(bool on) => _record('setHistogramEnabled($on)'); +} diff --git a/Fly App/test/fly/profile_fixtures.dart b/Fly App/test/fly/profile_fixtures.dart index b1bc612..83756bf 100644 --- a/Fly App/test/fly/profile_fixtures.dart +++ b/Fly App/test/fly/profile_fixtures.dart @@ -7,6 +7,7 @@ Map _camera({ bool aperture = false, bool ssd = false, bool zoom = false, + bool hybridZoom = false, List apertures = const [], }) => { @@ -15,9 +16,10 @@ Map _camera({ 'isThermal': false, 'ssdSupported': ssd, 'opticalZoomSupported': zoom, - 'hybridZoomSupported': false, + 'hybridZoomSupported': hybridZoom, 'digitalZoomSupported': false, 'adjustableApertureSupported': aperture, + 'adjustableFocalPointSupported': aperture, 'meteringSupported': true, 'playbackSupported': true, 'mediaDownloadSupported': true, @@ -27,7 +29,14 @@ Map _camera({ 'shutterSpeeds': ['1/8000', '1/1000', '1/100', '1/30'], 'apertures': apertures, 'exposureCompensations': ['-3.0', '0.0', '+3.0'], - 'videoResolutions': ['RESOLUTION_3840x2160@FRAME_RATE_30_FPS'], + 'videoResolutions': [ + 'RESOLUTION_3840x2160@FRAME_RATE_29_DOT_970_FPS', + 'RESOLUTION_1920x1080@FRAME_RATE_59_DOT_940_FPS', + ], + 'photoModes': ['SINGLE', 'HDR', 'BURST', 'AEB', 'INTERVAL'], + 'whiteBalancePresets': ['AUTO', 'SUNNY', 'CLOUDY', 'CUSTOM'], + 'videoStandards': ['NTSC', 'PAL'], + 'colors': ['NONE', 'D_LOG'], }; Map _gimbal({bool yaw = false, double pitchMax = 0}) => { @@ -106,3 +115,20 @@ final Map inspire2X5s = { {'index': 1, 'isSmart': true, 'cells': 6}, ], }; + +/// Mavic 2 Zoom: hybrid zoom (optical 24–48 mm, then digital), fixed aperture. +final Map mavic2Zoom = { + 'type': 'productProfile', + 'connected': true, + 'model': 'MAVIC_2_ZOOM', + 'modelDisplayName': 'Mavic 2 Zoom', + 'productKind': 'aircraft', + 'cameras': [_camera(name: 'FC2204', zoom: true, hybridZoom: true)], + 'gimbals': [_gimbal()], + 'link': 'ocusync', + 'flightController': _fc(), + 'remoteController': {'masterSlaveSupported': false}, + 'batteries': [ + {'index': 0, 'isSmart': true, 'cells': 4}, + ], +}; diff --git a/Fly App/test/fly/safety_test.dart b/Fly App/test/fly/safety_test.dart index 0472958..a5509f6 100644 --- a/Fly App/test/fly/safety_test.dart +++ b/Fly App/test/fly/safety_test.dart @@ -13,56 +13,10 @@ import 'package:pilotvault_fly/ui/fly/dialogs/slide_to_confirm.dart'; import 'package:pilotvault_fly/ui/fly/fly_screen.dart'; import 'package:pilotvault_fly/ui/fly/panels/aircraft_status_sheet.dart'; +import 'fake_dji.dart'; import 'profile_fixtures.dart'; import 'telemetry_fixtures.dart'; -/// Records every bridge call instead of reaching the platform channel. -class FakeDjiService extends DjiService { - final List calls = []; - Object? failWith; - - Future _record(String call) async { - calls.add(call); - if (failWith != null) throw failWith!; - } - - @override - Future takeOff() => _record('takeOff'); - @override - Future precisionTakeOff() => _record('precisionTakeOff'); - @override - Future land() => _record('land'); - @override - Future cancelLanding() => _record('cancelLanding'); - @override - Future confirmLanding() => _record('confirmLanding'); - @override - Future startGoHome() => _record('startGoHome'); - @override - Future cancelGoHome() => _record('cancelGoHome'); - @override - Future confirmSmartRTH(bool goHome) => _record('confirmSmartRTH($goHome)'); - @override - Future setGoHomeHeight(int m) => _record('setGoHomeHeight($m)'); - @override - Future setHomeLocation(double latitude, double longitude) => _record('setHomeLocation'); - @override - Future stopWaypointMission() => _record('stopWaypointMission'); - @override - Future stopActiveTrack() => _record('stopActiveTrack'); - @override - Future startCompassCalibration() => _record('startCompassCalibration'); - @override - Future formatStorage({String location = 'SDCARD'}) => _record('formatStorage'); - @override - Future getKey(DjiKey key) async => switch (key.key) { - 'MAX_FLIGHT_HEIGHT' => 120, - 'MAX_FLIGHT_RADIUS' => 500, - 'MAX_FLIGHT_RADIUS_ENABLED' => true, - _ => null, - }; -} - Map grounded() => telemetryTick() ..['isFlying'] = false ..['areMotorsOn'] = false