Give the Fly App GO 4-style camera controls

One settings table in CameraBridge watches every camera value into the
telemetry tick and backs a generic setCameraValue, so the tabbed camera
menu (Exposure / Photo / Video / Other) is built from row specs and the
camera's own capability ranges. Writes show at once and fall back to what
the camera reports. Adds the quick exposure panel (Auto/A/S/M, ISO slider,
shutter and aperture wheels, EV strip, metered EV in M), a new camera
column with record timer and interval/pano progress, a zoom column for
zoom cameras, tap-to-focus / tap-to-meter, and a live histogram.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-19 19:57:38 +02:00
1 parent 6ebc567e97
commit c1770fff73
26 files changed
+3546 -171

No files matched your search

@@ -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: {<every SETTINGS id>, 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<String, Setting> = 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<String>("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<SettingsDefinitions.VideoResolution>(call.argument<String>("resolution"))
val fps = enumOrNull<SettingsDefinitions.VideoFrameRate>(call.argument<String>("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<Int>("count") ?: 255
val seconds = call.argument<Int>("seconds") ?: 5
it.setPhotoTimeIntervalSettings(
SettingsDefinitions.PhotoTimeIntervalSettings(count, seconds),
ctx.completion(result),
)
}
"setPictureStyle" -> guard(camera, result) {
val style = SettingsDefinitions.PictureStylePreset.Builder()
.sharpness(call.argument<Int>("sharpness") ?: 0)
.contrast(call.argument<Int>("contrast") ?: 0)
.saturation(call.argument<Int>("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<Double>("x")), unit(call.argument<Double>("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<Double>("x")) * cols).toInt().coerceIn(0, cols - 1)
val row = (unit(call.argument<Double>("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<String>("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<Int>("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<Double>("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<Boolean>("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<String>("id")
val s = settings[id]
if (s == null) {
ctx.fail(result, "Unknown camera setting $id")
return
}
val raw = call.argument<Any>("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<Int, Int> {
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<String, Any?>) = 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<SettingsDefinitions.WhiteBalancePreset>(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.
@@ -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<String> {
val index = probe { cam.index } ?: 0
val v = probe { KeyManager.getInstance()?.getValue(CameraKey.create(key, index)) }
val items: List<Any?> = 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<out Enum<*>?>?, label: (String?) -> String? = { it }): List<String> =
values.orEmpty()
+48 -1
View File
@@ -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<void> setHistogramEnabled(bool on) =>
_methods.invokeMethod<void>('setHistogramEnabled', <String, dynamic>{'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<void> setCameraValue(String id, Object value) =>
_methods.invokeMethod<void>('setCameraValue', <String, dynamic>{'id': id, 'value': value});
/// resolution / frameRate: SDK enum names (`RESOLUTION_3840x2160`, `FRAME_RATE_30_FPS`).
Future<void> setVideoResolutionAndFrameRate(String resolution, String frameRate) =>
_methods.invokeMethod<void>('setVideoResolutionAndFrameRate', <String, dynamic>{
'resolution': resolution,
'frameRate': frameRate,
});
/// Interval shooting: [count] photos (255 = until stopped) every [seconds].
Future<void> setPhotoTimeInterval({required int count, required int seconds}) =>
_methods.invokeMethod<void>('setPhotoTimeInterval', <String, dynamic>{'count': count, 'seconds': seconds});
/// The picture style, each −3…+3 on most cameras.
Future<void> setPictureStyle({required int sharpness, required int contrast, required int saturation}) =>
_methods.invokeMethod<void>('setPictureStyle', <String, dynamic>{
'sharpness': sharpness,
'contrast': contrast,
'saturation': saturation,
});
/// Tap-to-focus at x/y, each 0..1 across the live view.
Future<void> setFocusTarget(double x, double y) =>
_methods.invokeMethod<void>('setFocusTarget', <String, dynamic>{'x': x, 'y': y});
/// Spot metering at x/y (0..1); switches the metering mode to SPOT first.
Future<void> setSpotMeteringTarget(double x, double y) =>
_methods.invokeMethod<void>('setSpotMeteringTarget', <String, dynamic>{'x': x, 'y': y});
/// Optical zoom while a T / W button is held. direction: `in` | `out`.
Future<void> startContinuousZoom(String direction) =>
_methods.invokeMethod<void>('startContinuousZoom', <String, dynamic>{'direction': direction});
Future<void> stopContinuousZoom() => _methods.invokeMethod<void>('stopContinuousZoom');
/// Hybrid or optical focal length, in the units `cameraSettings.zoom*` report.
Future<void> setZoomFocalLength(int value) =>
_methods.invokeMethod<void>('setZoomFocalLength', <String, dynamic>{'value': value});
Future<void> setDigitalZoomFactor(double value) =>
_methods.invokeMethod<void>('setDigitalZoomFactor', <String, dynamic>{'value': value});
/// The camera's own "Reset camera settings".
Future<void> restoreCameraDefaults() => _methods.invokeMethod<void>('restoreCameraDefaults');
// ── Gimbal ──────────────────────────────────────────────────────────────────
Future<void> rotateGimbalPitch(double deg) =>
_methods.invokeMethod<void>('rotateGimbalPitch', <String, dynamic>{'pitch': deg});
+142
View File
@@ -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<String> _isos = <String>['100', '200', '400', '800', '1600', '3200'];
static const List<String> _shutters = <String>[
'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<String> _evs = <String>[
'-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<ExposureMode> modes(CameraProfile? c) {
final List<ExposureMode> reported = <ExposureMode>[
for (final String w in c?.exposureModes ?? const <String>[])
if (ExposureMode.fromWire(w) case final ExposureMode m) m,
];
final List<ExposureMode> all = reported.isEmpty ? ExposureMode.values : reported;
final bool aperture = c == null || c.adjustableApertureSupported;
return <ExposureMode>[
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<String> isos(CameraProfile? c) {
final List<String> l = <String>[for (final String v in c?.isos ?? const <String>[]) 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<String> shutters(CameraProfile? c) =>
(c?.shutterSpeeds.isNotEmpty ?? false) ? c!.shutterSpeeds : _shutters;
static List<String> apertures(CameraProfile? c) => c?.apertures ?? const <String>[];
/// Lowest first.
static List<String> evs(CameraProfile? c) {
final List<String> l = (c?.exposureCompensations.isNotEmpty ?? false) ? c!.exposureCompensations : _evs;
return List<String>.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<String> 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<String> 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;
}
+579
View File
@@ -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<CameraOption> Function(ProductProfile p, CameraState c) options;
final String? Function(CameraState c) current;
final Future<void> Function(DjiService dji, CameraState c, String value) apply;
/// The settings to show as pending while [apply] runs; `{id: value}` by default.
final Map<String, Object?> Function(String value)? pendingFor;
/// The settings [pendingFor] writes; `[id]` by default.
final List<String>? pendingKeys;
Map<String, Object?> pending(String value) => pendingFor?.call(value) ?? <String, Object?>{id: value};
List<String> get writes => pendingKeys ?? <String>[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<void> 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<void> 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<void> 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<CameraRow> rowsFor(CameraMenuTab tab, ProductProfile p, CameraState c) => <CameraRow>[
for (final CameraRow r in all)
if (r.tab == tab && r.visible(p, c)) r,
];
// ── Option helpers ─────────────────────────────────────────────────────────
static List<CameraOption> _opts(Iterable<String> values) =>
<CameraOption>[for (final String v in values) CameraOption(v, CameraLabels.of(v))];
/// The camera's own range when it published one, else [fallback].
static List<CameraOption> _ranged(List<String>? reported, List<String> fallback) =>
_opts((reported != null && reported.isNotEmpty) ? reported : fallback);
static Future<void> _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<CameraRow> all = <CameraRow>[
// 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 <String>['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>[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 <String>['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 <String>['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 <String>['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: (_, __) => <CameraOption>[
for (final int s in const <int>[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) => <String, Object?>{'intervalSeconds': int.parse(v)},
),
ChoiceRow(
id: 'panoramaMode',
label: 'Panorama',
tab: CameraMenuTab.photo,
reportedBy: 'panoramaMode',
requires: (_, CameraState c) => _mode(c, 'PANORAMA'),
options: (_, __) => _opts(const <String>[
'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 <String>['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(<String>[
'JPEG',
'RAW',
'RAW_AND_JPEG',
if (p.hasThermal) ...<String>['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, _) => <CameraOption>[
for (final String v in p.camera?.videoResolutions ?? const <String>[]) 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<String> parts = v.split('@');
return d.setVideoResolutionAndFrameRate(parts.first, parts.last);
},
pendingFor: (String v) {
final List<String> parts = v.split('@');
return <String, Object?>{'videoResolution': parts.first, 'videoFrameRate': parts.last};
},
pendingKeys: const <String>['videoResolution', 'videoFrameRate'],
),
ChoiceRow(
id: 'videoFileFormat',
label: 'Video format',
tab: CameraMenuTab.video,
reportedBy: 'videoFileFormat',
options: (_, __) => _opts(const <String>['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 <String>['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 <String>['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 <String>['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 <String>['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 <String>['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<void> _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<String, String> _known = <String, String>{
'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<String> 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';
}
}
+53
View File
@@ -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<bool> perfOverlay = ValueNotifier<bool>(false);
static final ValueNotifier<GridStyle> grid = ValueNotifier<GridStyle>(GridStyle.off);
static final ValueNotifier<bool> centerPoint = ValueNotifier<bool>(true);
static final ValueNotifier<LongPressAction> longPress = ValueNotifier<LongPressAction>(LongPressAction.meter);
static SharedPreferences? _prefs;
static Future<void> 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<void> setGrid(GridStyle g) async {
grid.value = g;
await _prefs?.setString(_gridKey, g.name);
}
static Future<void> setCenterPoint(bool on) async {
centerPoint.value = on;
await _prefs?.setBool(_centerKey, on);
}
static Future<void> setLongPress(LongPressAction a) async {
longPress.value = a;
await _prefs?.setString(_longPressKey, a.name);
}
static T? _byName<T extends Enum>(List<T> values, String? name) {
for (final T v in values) {
if (v.name == name) return v;
}
return null;
}
}
+9
View File
@@ -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,
+19
View File
@@ -47,6 +47,11 @@ class CameraProfile {
this.apertures = const <String>[],
this.exposureCompensations = const <String>[],
this.videoResolutions = const <String>[],
this.photoModes = const <String>[],
this.whiteBalancePresets = const <String>[],
this.antiFlickerModes = const <String>[],
this.videoStandards = const <String>[],
this.colors = const <String>[],
});
factory CameraProfile.fromMap(Map<String, dynamic> 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<String> shutterSpeeds;
final List<String> apertures;
final List<String> exposureCompensations;
/// `RESOLUTION_3840x2160@FRAME_RATE_29_DOT_970_FPS` pairs.
final List<String> videoResolutions;
// SDK enum names, from the key cache's range keys.
final List<String> photoModes; // SINGLE / HDR / BURST / AEB / INTERVAL / PANORAMA …
final List<String> whiteBalancePresets; // AUTO / SUNNY / CLOUDY / … / CUSTOM
final List<String> antiFlickerModes; // AUTO / MANUAL_50HZ / MANUAL_60HZ
final List<String> videoStandards; // PAL / NTSC
final List<String> 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;
+135 -18
View File
@@ -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<String, dynamic> 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<String, dynamic> get settings => _pending.isEmpty ? _reported : <String, dynamic>{..._reported, ..._pending};
/// Only what the camera itself reported.
Map<String, dynamic> get reportedSettings => _reported;
Map<String, dynamic> _reported = const <String, dynamic>{};
/// 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<String, Object?> _pending = <String, Object?>{};
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<List<int>?> histogram = ValueNotifier<List<int>?>(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<String, dynamic> e) {
final Object? bins = e['bins'];
histogram.value = bins is List ? <int>[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<String, Object?> values) {
_pending.addAll(values);
notifyListeners();
}
/// Rolls [ids] back to what the camera last reported.
void dropPending(Iterable<String> 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<String, dynamic> reported = settings ?? const <String, dynamic>{};
// 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 <String, dynamic>{};
_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();
}
+3
View File
@@ -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;
+58 -10
View File
@@ -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<FlyScreen> {
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<FlyScreen> {
// the swap moves the native video view instead of recreating it.
if (!_mapBig) ...<Widget>[
Positioned.fromRect(key: const ValueKey<String>('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<String>('map-slot'), rect: pip, child: map),
] else ...<Widget>[
Positioned.fromRect(key: const ValueKey<String>('map-slot'), rect: full, child: map),
@@ -191,12 +209,32 @@ class _FlyScreenState extends State<FlyScreen> {
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<FlyScreen> {
),
),
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<FlyScreen> {
Positioned(
left: 0,
top: 60,
child: ActionRail(
model: _m,
flow: _flow,
grid: _grid,
onGrid: () => setState(() => _grid = !_grid),
child: ValueListenableBuilder<GridStyle>(
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)),
+24 -10
View File
@@ -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: <Widget>[
if (grid) const CustomPaint(painter: GridPainter()),
const Center(child: PVIcon('crosshair', size: 30, stroke: 1.1, color: Color(0xB3FFFFFF))),
ValueListenableBuilder<GridStyle>(
valueListenable: FlyPrefs.grid,
builder: (BuildContext context, GridStyle g, _) =>
g == GridStyle.off ? const SizedBox.shrink() : CustomPaint(painter: GridPainter(diagonals: g == GridStyle.diagonals)),
),
ValueListenableBuilder<bool>(
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
@@ -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<CameraTouchLayer> createState() => _CameraTouchLayerState();
}
enum _Mark { focus, meter }
class _CameraTouchLayerState extends State<CameraTouchLayer> {
Offset? _focusAt;
Offset? _meterAt;
Timer? _focusFade;
@override
void dispose() {
_focusFade?.cancel();
super.dispose();
}
Future<void> _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<void> _cancelMeter() async {
setState(() => _meterAt = null);
if (!widget.connected) return;
await writeCameraSetting(
widget.camera,
label: 'Metering',
pending: const <String, Object?>{'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<LongPressAction>(
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<String>('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: <Widget>[
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: <Widget>[
Positioned.fill(
child: IgnorePointer(
child: DecoratedBox(decoration: BoxDecoration(border: Border.all(color: color, width: 1.5))),
),
),
if (label != null) ...<Widget>[
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<String>('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)),
),
),
),
],
]),
);
}
}
@@ -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<List<int>?>(
valueListenable: camera.histogram,
builder: (BuildContext context, List<int>? bins, _) {
if (bins == null || bins.isEmpty) return const SizedBox.shrink();
return Container(
key: const ValueKey<String>('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<int> 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);
}
@@ -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<void> showCameraMenu(
BuildContext context, {
required FlightModel model,
required DjiService dji,
required void Function(String label, Object error) onError,
CameraMenuTab initialTab = CameraMenuTab.exposure,
}) {
return showGeneralDialog<void>(
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<double> a, _, Widget child) => SlideTransition(
position: Tween<Offset>(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<CameraMenuSheet> createState() => _CameraMenuSheetState();
}
class _CameraMenuSheetState extends State<CameraMenuSheet> {
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: <Widget>[
Padding(
padding: const EdgeInsets.fromLTRB(10, 6, 4, 6),
child: Row(children: <Widget>[
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<String>('camera-menu-${_tab.name}'),
padding: const EdgeInsets.only(bottom: 16),
children: <Widget>[
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<Widget> _displayRows() => <Widget>[
const Padding(
padding: EdgeInsets.fromLTRB(16, 14, 16, 4),
child: Text('DISPLAY', style: HudText.label),
),
ValueListenableBuilder<GridStyle>(
valueListenable: FlyPrefs.grid,
builder: (BuildContext context, GridStyle g, _) => ChoiceTile(
key: const ValueKey<String>('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<bool>(
valueListenable: FlyPrefs.centerPoint,
builder: (BuildContext context, bool on, _) =>
PrefSwitchTile(key: const ValueKey<String>('row-centerPoint'), label: 'Centre point', value: on, onChanged: FlyPrefs.setCenterPoint),
),
Requires(
HudFeatures.spotMetering,
child: ValueListenableBuilder<LongPressAction>(
valueListenable: FlyPrefs.longPress,
builder: (BuildContext context, LongPressAction a, _) => ChoiceTile(
key: const ValueKey<String>('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<String>('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)),
),
),
);
}
}
@@ -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<String>('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<String>('row-${r.id}'),
label: r.label,
value: r.current(camera),
onChanged: (bool on) => writeCameraSetting(
camera,
label: r.label,
pending: <String, Object?>{r.id: on},
send: () => r.apply(dji, on),
onError: onError,
),
),
StepperRow() => _StepperTile(
key: ValueKey<String>('row-${r.id}'),
row: r,
value: r.current(camera),
onChanged: (int v) => writeCameraSetting(
camera,
label: r.label,
pending: <String, Object?>{r.id: v},
send: () => r.apply(dji, camera, v),
onError: onError,
),
),
ActionRow() => _ActionTile(key: ValueKey<String>('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<String> onSelect;
/// A write is waiting for the camera's answer.
final bool pending;
@override
State<ChoiceTile> createState() => _ChoiceTileState();
}
class _ChoiceTileState extends State<ChoiceTile> {
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: <Widget>[
_RowFrame(
label: widget.label,
onTap: widget.options.isEmpty ? null : () => setState(() => _open = !_open),
trailing: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
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: <Widget>[
for (final (String v, String l) in widget.options)
GestureDetector(
key: ValueKey<String>('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: <Widget>[
Expanded(
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
Text(label, style: HudText.sans.copyWith(fontSize: 13)),
if (hint != null) ...<Widget>[
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<bool> 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<bool> 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<int> 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<String>('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: <Widget>[
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<void> _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<String>('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)),
),
),
),
);
}
}
@@ -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<void> showQuickExposure(
BuildContext context, {
required FlightModel model,
required DjiService dji,
required void Function(String label, Object error) onError,
}) {
return showGeneralDialog<void>(
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<double> 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<ExposureMode> modes = ExposureOptions.modes(profile);
final ExposureMode mode = ExposureMode.fromWire(camera.exposureMode) ?? ExposureMode.auto;
final List<String> isos = ExposureOptions.isos(profile);
final bool isoAuto = camera.isoSetting == 'AUTO';
final List<String> evs = ExposureOptions.evs(profile);
final List<String> apertures = ExposureOptions.apertures(profile);
return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
_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<String>('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<String>('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: <Widget>[
_StepButton(
key: const ValueKey<String>('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<String>('ev-plus'),
label: '+',
onTap: mode.evEditable ? () => _stepEv(evs, 1) : null,
),
]),
),
]);
},
);
}
Future<void> _send(String label, Future<void> Function() call) async {
try {
await call();
} catch (e) {
onError(label, e);
}
}
Future<void> _setMode(ExposureMode m) => writeCameraSetting(
camera,
label: 'Exposure mode',
pending: <String, Object?>{'exposureMode': m.wire},
send: () => dji.setCameraValue('exposureMode', m.wire),
onError: onError,
);
Future<void> _setIso(String v) => writeCameraSetting(
camera,
label: 'ISO',
pending: <String, Object?>{'isoSetting': v},
send: () => dji.setISO(v),
onError: onError,
);
Future<void> _stepEv(List<String> 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<ExposureMode> modes;
final ExposureMode selected;
final ValueChanged<ExposureMode> 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: <Widget>[
for (final ExposureMode m in modes)
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onSelect(m),
child: Container(
key: ValueKey<String>('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: <Widget>[
Row(children: <Widget>[
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<String> isos;
final String? current;
final bool enabled;
final ValueChanged<String> 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<String>('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<String> values;
final String? current;
final ValueChanged<String> onSelected;
final bool enabled;
final double itemWidth;
final double height;
@override
State<HudWheel> createState() => _HudWheelState();
}
class _HudWheelState extends State<HudWheel> {
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: <Widget>[
NotificationListener<ScrollNotification>(
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<String> evs;
final double? current;
final bool metered;
@override
void paint(Canvas canvas, Size size) {
final List<double> vs = <double>[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;
}
@@ -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<void> writeCameraSetting(
CameraState camera, {
required String label,
required Map<String, Object?> pending,
required Future<void> 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));
}
+236 -50
View File
@@ -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<CameraColumn> {
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<void> _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<void> _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<void> _toggleAeLock() {
final bool next = !(_cam.aeLock ?? false);
return writeCameraSetting(
_cam,
label: 'AE lock',
pending: <String, Object?>{'aeLock': next},
send: () => widget.dji.setCameraValue('aeLock', next),
onError: widget.onError,
);
}
@override
@@ -66,67 +90,229 @@ class _CameraColumnState extends State<CameraColumn> {
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: <Widget>[
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<String>('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: <Widget>[
_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<String>('mode-photo')),
_modeBtn('video', active: video, onTap: () => _setMode(true), key: const ValueKey<String>('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<String>('camera-menu'), label: 'MENU', width: 44, height: 28, onTap: widget.onCameraMenu),
const SizedBox(height: 8),
Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
Requires(
HudFeatures.playback,
child: _Round(key: const ValueKey<String>('playback'), icon: 'play', onTap: widget.onPlayback),
),
const SizedBox(width: 4),
_Round(key: const ValueKey<String>('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<ShutterButton> createState() => _ShutterButtonState();
}
class _ShutterButtonState extends State<ShutterButton> 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<String>('shutter'),
onTap: widget.onTap,
child: SizedBox(
width: 60,
height: 60,
child: Stack(alignment: Alignment.center, children: <Widget>[
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),
),
);
}
+114
View File
@@ -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<void> _run(String label, Future<void> 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<String>(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: <Widget>[
button('T', true),
const SizedBox(height: 4),
Text(
z == null ? '—' : '${z.factor.toStringAsFixed(1)}×',
key: const ValueKey<String>('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<String>('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)),
),
),
);
}
}
@@ -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<String>('camera-info'),
height: 22,
onTap: onTap,
child: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
_sd(sd),
Requires(HudFeatures.ssdStatus, whenDisconnected: false, child: _ssd(ssd)),
+2
View File
@@ -133,6 +133,8 @@ class ServerUploader {
void onEvent(Map<String, dynamic> 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();
+422
View File
@@ -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<String, dynamic> profile, {
Map<String, Object?> settings = const <String, Object?>{},
Map<String, Object?> system = const <String, Object?>{},
}) {
final FlightModel m = FlightModel()
..connected = true
..profile = ProductProfile.fromBridge(profile);
final TelemetryReducer r = TelemetryReducer(m);
final Map<String, dynamic> t = telemetryTick();
t['cameraSettings'] = <Object?, Object?>{
...(t['cameraSettings'] as Map<Object?, Object?>),
'exposureMode': 'PROGRAM',
'isoSetting': 'AUTO',
'shootPhotoMode': 'SINGLE',
'meteringMode': 'CENTER',
'whiteBalancePreset': 'AUTO',
'histogramEnabled': false,
...settings,
};
r.apply(t);
r.apply(<String, dynamic>{'type': 'camera', 'isRecording': false, 'mode': 'SHOOT_PHOTO', ...system});
r.apply(<String, dynamic>{'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<String, Object?> settings) {
m.camera.applyTelemetry(settings: <String, dynamic>{...m.camera.reportedSettings, ...settings});
}
void main() {
group('Exposure rules', () {
test('A only on adjustable-aperture lenses', () {
const List<String> all = <String>['PROGRAM', 'SHUTTER_PRIORITY', 'APERTURE_PRIORITY', 'MANUAL'];
expect(ExposureOptions.modes(const CameraProfile(exposureModes: all)),
<ExposureMode>[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>[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), <String>['100', '200', '400', '800', '1600'], reason: 'AUTO is its own toggle');
expect(ExposureOptions.shutters(c), <String>['1/8000', '1/1000', '1/100', '1/30']);
expect(ExposureOptions.isos(const CameraProfile()), isNotEmpty);
expect(ExposureOptions.evs(c), <String>['-3.0', '0.0', '+3.0']);
});
test('EV steps to the neighbour in the camera range and stops at the ends', () {
final List<String> 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<String> s = <String>['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 <String>['f/2.8', 'f/4', 'f/5.6'], 'f/4.5'), 1);
});
test('meter verdict', () {
final CameraState c = CameraState()..applyExposure(<String, dynamic>{'ev': '-1.0'});
expect(meterVerdict(c), MeterVerdict.under);
c.applyExposure(<String, dynamic>{'ev': '+0.3'});
expect(meterVerdict(c), MeterVerdict.ok);
});
});
group('Zoom', () {
test('factor, steps and the digital range', () {
final CameraState c = CameraState()
..applyTelemetry(settings: <String, dynamic>{
'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: <String, dynamic>{...c.reportedSettings, 'zoomFocalLength': 600});
expect(c.zoom!.digital, isTrue);
});
test('no zoom without a spec', () {
expect((CameraState()..applyTelemetry(settings: <String, dynamic>{'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(<String, Object?>{'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: <String, dynamic>{'inserted': true}, settings: c.reportedSettings);
expect(c.isPending('photoAspectRatio'), isTrue);
reportSettings(m, <String, Object?>{'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(<String, Object?>{'photoAspectRatio': 'RATIO_16_9'});
c.dropPending(<String>['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: <String, Object?>{'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<String> 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: <String, Object?>{'burstCount': 'BURST_COUNT_3', 'intervalSeconds': 5});
expect(ids(CameraMenuTab.photo, m), <String>['shootPhotoMode', 'photoAspectRatio', 'photoFileFormat']);
m = cameraModel(phantom4, settings: <String, Object?>{'shootPhotoMode': 'BURST', 'burstCount': 'BURST_COUNT_3'});
expect(ids(CameraMenuTab.photo, m), contains('burstCount'));
m = cameraModel(phantom4, settings: <String, Object?>{'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: <String, Object?>{'videoCaption': true})), contains('videoCaption'));
});
test('product predicates hide rows', () {
expect(ids(CameraMenuTab.exposure, cameraModel(phantom4, settings: <String, Object?>{'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: <String, Object?>{'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<ChoiceRow>().firstWhere((ChoiceRow r) => r.id == 'whiteBalancePreset');
expect(wb.options(m.profile, m.camera).map((CameraOption o) => o.value), <String>['AUTO', 'SUNNY', 'CLOUDY', 'CUSTOM']);
final ChoiceRow size = CameraMenu.all.whereType<ChoiceRow>().firstWhere((ChoiceRow r) => r.id == 'videoSize');
expect(size.options(m.profile, m.camera).map((CameraOption o) => o.label), <String>['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<ActionRow>().firstWhere((ActionRow r) => r.id == 'formatSd');
final FlightModel m = cameraModel(phantom4, system: <String, Object?>{'isRecording': true});
expect(format.blockedReason(m.camera), 'Stop shooting first');
expect(format.blockedReason(cameraModel(phantom4).camera), isNull);
});
});
group('Camera HUD', () {
Future<FakeDjiService> 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<void> openMenu(WidgetTester tester, String tab) async {
await tester.tap(find.byKey(const ValueKey<String>('camera-menu')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.byKey(ValueKey<String>('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<String>('row-photoAspectRatio')));
await tester.pump();
await tester.tap(find.byKey(const ValueKey<String>('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<String>('row-photoFileFormat')));
await tester.pump();
await tester.tap(find.byKey(const ValueKey<String>('opt-JPEG')));
await tester.pump();
reportSettings(m, <String, Object?>{'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<String>('row-videoFileFormat')));
await tester.pump();
await tester.tap(find.byKey(const ValueKey<String>('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<String>('camera-info')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.text('EV'), findsWidgets);
await tester.tap(find.byKey(const ValueKey<String>('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<String>('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<String>('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<String>('zoom-in')), findsNothing);
final FakeDjiService dji = await pump(
tester,
cameraModel(mavic2Zoom, settings: <String, Object?>{
'zoomFocalLength': 240,
'zoomMin': 240,
'zoomMax': 960,
'zoomOpticalMax': 480,
'zoomStep': 10,
}),
);
expect(find.text('1.0×'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey<String>('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, <String, Object?>{'meteringMode': 'SPOT'});
await tester.pump();
expect(find.text('Exposure'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey<String>('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: <String, Object?>{'shootPhotoMode': 'INTERVAL', 'intervalSeconds': 5}, system: <String, Object?>{'shootingInterval': true});
final FakeDjiService dji = await pump(tester, m);
expect(find.text('INTERVAL 5s'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey<String>('shutter')));
await tester.pump();
expect(dji.calls, <String>['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<String>('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<String>('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>[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<String>('camera-menu')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
for (final String t in <String>['exposure', 'photo', 'video', 'other']) {
await tester.tap(find.byKey(ValueKey<String>('tab-$t')));
await tester.pump();
expect(tester.takeException(), isNull, reason: '$s $t');
}
}
});
});
}
+95
View File
@@ -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<String> calls = <String>[];
Object? failWith;
Future<void> _record(String call) async {
calls.add(call);
if (failWith != null) throw failWith!;
}
@override
Future<void> takeOff() => _record('takeOff');
@override
Future<void> precisionTakeOff() => _record('precisionTakeOff');
@override
Future<void> land() => _record('land');
@override
Future<void> cancelLanding() => _record('cancelLanding');
@override
Future<void> confirmLanding() => _record('confirmLanding');
@override
Future<void> startGoHome() => _record('startGoHome');
@override
Future<void> cancelGoHome() => _record('cancelGoHome');
@override
Future<void> confirmSmartRTH(bool goHome) => _record('confirmSmartRTH($goHome)');
@override
Future<void> setGoHomeHeight(int m) => _record('setGoHomeHeight($m)');
@override
Future<void> setHomeLocation(double latitude, double longitude) => _record('setHomeLocation');
@override
Future<void> stopWaypointMission() => _record('stopWaypointMission');
@override
Future<void> stopActiveTrack() => _record('stopActiveTrack');
@override
Future<void> startCompassCalibration() => _record('startCompassCalibration');
@override
Future<void> formatStorage({String location = 'SDCARD'}) => _record('formatStorage');
@override
Future<Object?> 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<void> setCameraMode(String mode) => _record('setCameraMode($mode)');
@override
Future<void> startShootPhoto() => _record('startShootPhoto');
@override
Future<void> stopShootPhoto() => _record('stopShootPhoto');
@override
Future<void> startRecordVideo() => _record('startRecordVideo');
@override
Future<void> stopRecordVideo() => _record('stopRecordVideo');
@override
Future<void> setCameraValue(String id, Object value) => _record('setCameraValue($id, $value)');
@override
Future<void> setISO(String value) => _record('setISO($value)');
@override
Future<void> setShutterSpeed(String value) => _record('setShutterSpeed($value)');
@override
Future<void> setAperture(String value) => _record('setAperture($value)');
@override
Future<void> setEV(String value) => _record('setEV($value)');
@override
Future<void> setWhiteBalance(String value) => _record('setWhiteBalance($value)');
@override
Future<void> setVideoResolutionAndFrameRate(String resolution, String frameRate) =>
_record('setVideoResolutionAndFrameRate($resolution, $frameRate)');
@override
Future<void> setPhotoTimeInterval({required int count, required int seconds}) => _record('setPhotoTimeInterval($count, $seconds)');
@override
Future<void> setPictureStyle({required int sharpness, required int contrast, required int saturation}) =>
_record('setPictureStyle($sharpness, $contrast, $saturation)');
@override
Future<void> setFocusTarget(double x, double y) => _record('setFocusTarget(${x.toStringAsFixed(2)}, ${y.toStringAsFixed(2)})');
@override
Future<void> setSpotMeteringTarget(double x, double y) =>
_record('setSpotMeteringTarget(${x.toStringAsFixed(2)}, ${y.toStringAsFixed(2)})');
@override
Future<void> startContinuousZoom(String direction) => _record('startContinuousZoom($direction)');
@override
Future<void> stopContinuousZoom() => _record('stopContinuousZoom');
@override
Future<void> setZoomFocalLength(int value) => _record('setZoomFocalLength($value)');
@override
Future<void> restoreCameraDefaults() => _record('restoreCameraDefaults');
@override
Future<void> setHistogramEnabled(bool on) => _record('setHistogramEnabled($on)');
}
+28 -2
View File
@@ -7,6 +7,7 @@ Map<String, dynamic> _camera({
bool aperture = false,
bool ssd = false,
bool zoom = false,
bool hybridZoom = false,
List<String> apertures = const <String>[],
}) =>
<String, dynamic>{
@@ -15,9 +16,10 @@ Map<String, dynamic> _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<String, dynamic> _camera({
'shutterSpeeds': <String>['1/8000', '1/1000', '1/100', '1/30'],
'apertures': apertures,
'exposureCompensations': <String>['-3.0', '0.0', '+3.0'],
'videoResolutions': <String>['RESOLUTION_3840x2160@FRAME_RATE_30_FPS'],
'videoResolutions': <String>[
'RESOLUTION_3840x2160@FRAME_RATE_29_DOT_970_FPS',
'RESOLUTION_1920x1080@FRAME_RATE_59_DOT_940_FPS',
],
'photoModes': <String>['SINGLE', 'HDR', 'BURST', 'AEB', 'INTERVAL'],
'whiteBalancePresets': <String>['AUTO', 'SUNNY', 'CLOUDY', 'CUSTOM'],
'videoStandards': <String>['NTSC', 'PAL'],
'colors': <String>['NONE', 'D_LOG'],
};
Map<String, dynamic> _gimbal({bool yaw = false, double pitchMax = 0}) => <String, dynamic>{
@@ -106,3 +115,20 @@ final Map<String, dynamic> inspire2X5s = <String, dynamic>{
<String, dynamic>{'index': 1, 'isSmart': true, 'cells': 6},
],
};
/// Mavic 2 Zoom: hybrid zoom (optical 24–48 mm, then digital), fixed aperture.
final Map<String, dynamic> mavic2Zoom = <String, dynamic>{
'type': 'productProfile',
'connected': true,
'model': 'MAVIC_2_ZOOM',
'modelDisplayName': 'Mavic 2 Zoom',
'productKind': 'aircraft',
'cameras': <dynamic>[_camera(name: 'FC2204', zoom: true, hybridZoom: true)],
'gimbals': <dynamic>[_gimbal()],
'link': 'ocusync',
'flightController': _fc(),
'remoteController': <String, dynamic>{'masterSlaveSupported': false},
'batteries': <dynamic>[
<String, dynamic>{'index': 0, 'isSmart': true, 'cells': 4},
],
};
+1 -47
View File
@@ -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<String> calls = <String>[];
Object? failWith;
Future<void> _record(String call) async {
calls.add(call);
if (failWith != null) throw failWith!;
}
@override
Future<void> takeOff() => _record('takeOff');
@override
Future<void> precisionTakeOff() => _record('precisionTakeOff');
@override
Future<void> land() => _record('land');
@override
Future<void> cancelLanding() => _record('cancelLanding');
@override
Future<void> confirmLanding() => _record('confirmLanding');
@override
Future<void> startGoHome() => _record('startGoHome');
@override
Future<void> cancelGoHome() => _record('cancelGoHome');
@override
Future<void> confirmSmartRTH(bool goHome) => _record('confirmSmartRTH($goHome)');
@override
Future<void> setGoHomeHeight(int m) => _record('setGoHomeHeight($m)');
@override
Future<void> setHomeLocation(double latitude, double longitude) => _record('setHomeLocation');
@override
Future<void> stopWaypointMission() => _record('stopWaypointMission');
@override
Future<void> stopActiveTrack() => _record('stopActiveTrack');
@override
Future<void> startCompassCalibration() => _record('startCompassCalibration');
@override
Future<void> formatStorage({String location = 'SDCARD'}) => _record('formatStorage');
@override
Future<Object?> getKey(DjiKey key) async => switch (key.key) {
'MAX_FLIGHT_HEIGHT' => 120,
'MAX_FLIGHT_RADIUS' => 500,
'MAX_FLIGHT_RADIUS_ENABLED' => true,
_ => null,
};
}
Map<String, dynamic> grounded() => telemetryTick()
..['isFlying'] = false
..['areMotorsOn'] = false