Give the Fly App GO 4-style gimbal controls

Plan P5: a value bubble on the pitch slider while dragging, press-and-drag
on the video as a speed joystick (re-sent at 10 Hz, zero on release), and a
gimbal settings sheet: mode, 30° extension, yaw follow, recenter / face down /
yaw recenter, roll fine-tune, auto calibration and advanced speed/smoothness
rows gated by the gimbal's capability ranges.

GimbalBridge reports mode, trim, calibration and stop/overload state in the
gimbal telemetry group, and getter-only settings in a new gimbalSettings slow
group. The reported-plus-pending overlay moves out of CameraState into a
SettingsOverlay mixin shared by camera and gimbal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-19 20:22:42 +02:00
1 parent c1770fff73
commit 60c2fd7f64
19 files changed
+1600 -102

No files matched your search

@@ -1,7 +1,12 @@
package com.pilotvault.flyapp
import dji.common.error.DJIError
import dji.common.gimbal.Axis
import dji.common.gimbal.CapabilityKey
import dji.common.gimbal.GimbalMode
import dji.common.gimbal.Rotation
import dji.common.gimbal.RotationMode
import dji.common.util.CommonCallbacks
import dji.sdk.base.BaseProduct
import dji.sdk.gimbal.Gimbal
import dji.sdk.products.Aircraft
@@ -9,11 +14,91 @@ import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
/**
* Gimbal pitch control (from the flight HUD's pitch slider) and gimbal attitude,
* published as the `gimbal` fast group of the [TelemetryHub] (`{pitch, roll, yaw}`).
* Gimbal control and state (plan P5).
*
* - **Attitude and status** go out as the `gimbal` fast group of the
* [TelemetryHub]: `{pitch, roll, yaw, mode, yawRelative, *FineTune,
* calibrating, calibrationProgress, calibrationSuccessful, pitchAtStop,
* yawAtStop, motorOverloaded}`.
* - **Settings** the SDK only exposes as getters (pitch extension, yaw follow,
* the controller's smoothing / max speed / speed coefficient per axis) are
* read on bind, again once the gimbal has handshaken, and after every write,
* into the `gimbalSettings` slow group. A setting the gimbal does not support
* is never read, so it stays absent and its row hides.
* - **Control**: `rotateGimbalPitch` (absolute, the pitch slider),
* `rotateGimbalSpeed` (speed mode, the drag-on-video joystick — the caller
* repeats it at 10 Hz and ends with zero), `setGimbalMode`, `resetGimbal`,
* `resetGimbalYaw`, `fineTuneGimbal`, `startGimbalCalibration`,
* `setGimbalSetting {id, value}` and `resetGimbalControlParameters`.
*/
class GimbalBridge(private val ctx: BridgeCtx) : SubBridge {
/** A setting backed by an SDK getter / setter pair, by its `gimbalSettings` id. */
private class Setting(
val capability: CapabilityKey?,
val read: (Gimbal, CommonCallbacks.CompletionCallbackWith<Any>) -> Unit,
val write: (Gimbal, Any, CommonCallbacks.CompletionCallback<DJIError>) -> Unit,
)
private fun intSetting(
capability: CapabilityKey,
get: (Gimbal, CommonCallbacks.CompletionCallbackWith<Int>) -> Unit,
set: (Gimbal, Int, CommonCallbacks.CompletionCallback<DJIError>) -> Unit,
) = Setting(
capability,
{ g, cb -> get(g, forward(cb)) },
{ g, v, cb -> set(g, (v as Number).toInt(), cb) },
)
private fun <T> forward(cb: CommonCallbacks.CompletionCallbackWith<Any>) = object : CommonCallbacks.CompletionCallbackWith<T> {
override fun onSuccess(value: T) = cb.onSuccess(value as Any)
override fun onFailure(error: DJIError) = cb.onFailure(error)
}
private val settings: Map<String, Setting> = mapOf(
"pitchExtension" to Setting(
CapabilityKey.PITCH_RANGE_EXTENSION,
{ g, cb -> g.getPitchRangeExtensionEnabled(forward(cb)) },
{ g, v, cb -> g.setPitchRangeExtensionEnabled(v as Boolean, cb) },
),
// No capability key: read it, and a gimbal without it answers with an error.
"yawFollow" to Setting(
null,
{ g, cb -> g.getYawSimultaneousFollowEnabled(forward(cb)) },
{ g, v, cb -> g.setYawSimultaneousFollowEnabled(v as Boolean, cb) },
),
"pitchSmoothing" to intSetting(
CapabilityKey.PITCH_CONTROLLER_SMOOTHING_FACTOR,
{ g, cb -> g.getControllerSmoothingFactor(Axis.PITCH, cb) },
{ g, v, cb -> g.setControllerSmoothingFactor(Axis.PITCH, v, cb) },
),
"yawSmoothing" to intSetting(
CapabilityKey.YAW_CONTROLLER_SMOOTHING_FACTOR,
{ g, cb -> g.getControllerSmoothingFactor(Axis.YAW, cb) },
{ g, v, cb -> g.setControllerSmoothingFactor(Axis.YAW, v, cb) },
),
"pitchMaxSpeed" to intSetting(
CapabilityKey.PITCH_CONTROLLER_MAX_SPEED,
{ g, cb -> g.getControllerMaxSpeed(Axis.PITCH, cb) },
{ g, v, cb -> g.setControllerMaxSpeed(Axis.PITCH, v, cb) },
),
"yawMaxSpeed" to intSetting(
CapabilityKey.YAW_CONTROLLER_MAX_SPEED,
{ g, cb -> g.getControllerMaxSpeed(Axis.YAW, cb) },
{ g, v, cb -> g.setControllerMaxSpeed(Axis.YAW, v, cb) },
),
"pitchSpeedCoefficient" to intSetting(
CapabilityKey.PITCH_CONTROLLER_SPEED_COEFFICIENT,
{ g, cb -> g.getControllerSpeedCoefficient(Axis.PITCH, cb) },
{ g, v, cb -> g.setControllerSpeedCoefficient(Axis.PITCH, v, cb) },
),
"yawSpeedCoefficient" to intSetting(
CapabilityKey.YAW_CONTROLLER_SPEED_COEFFICIENT,
{ g, cb -> g.getControllerSpeedCoefficient(Axis.YAW, cb) },
{ g, v, cb -> g.setControllerSpeedCoefficient(Axis.YAW, v, cb) },
),
)
private fun gimbal(): Gimbal? = ctx.aircraft()?.gimbal
override fun handle(call: MethodCall, result: MethodChannel.Result): Boolean {
@@ -28,7 +113,54 @@ class GimbalBridge(private val ctx: BridgeCtx) : SubBridge {
.build()
it.rotate(rotation, ctx.completion(result))
}
"rotateGimbalSpeed" -> guard(g, result) {
// Degrees per second; an axis left at NO_ROTATION does not move.
val b = Rotation.Builder().mode(RotationMode.SPEED)
call.argument<Double>("pitch")?.let { p -> b.pitch(p.toFloat()) }
call.argument<Double>("yaw")?.let { y -> b.yaw(y.toFloat()) }
it.rotate(b.build(), ctx.completion(result))
}
"setGimbalMode" -> guard(g, result) {
val mode = enumByName<GimbalMode>(call.argument<String>("mode"))
if (mode == null || mode == GimbalMode.UNKNOWN) ctx.fail(result, "Unknown gimbal mode")
else it.setMode(mode, ctx.completion(result))
}
// Pitch and yaw back to centre (GO 4 "Recenter Gimbal").
"resetGimbal" -> guard(g, result) { it.reset(ctx.completion(result)) }
"resetGimbalYaw" -> guard(g, result) { it.resetYaw(ctx.completion(result)) }
"fineTuneGimbal" -> guard(g, result) {
val deg = (call.argument<Double>("degrees") ?: 0.0).toFloat()
when (call.argument<String>("axis")) {
"roll" -> it.fineTuneRollInDegrees(deg, ctx.completion(result))
"pitch" -> it.fineTunePitchInDegrees(deg, ctx.completion(result))
"yaw" -> it.fineTuneYawInDegrees(deg, ctx.completion(result))
else -> ctx.fail(result, "Unknown axis")
}
}
"startGimbalCalibration" -> guard(g, result) { it.startCalibration(ctx.completion(result)) }
"setGimbalSetting" -> guard(g, result) {
val id = call.argument<String>("id")
val s = settings[id]
val value = call.argument<Any>("value")
if (s == null || id == null || value == null) {
ctx.fail(result, "Unknown gimbal setting $id")
} else {
s.write(it, value, object : CommonCallbacks.CompletionCallback<DJIError> {
override fun onResult(error: DJIError?) {
ctx.completion(result).onResult(error)
readSetting(it, id, s)
}
})
}
}
"resetGimbalControlParameters" -> guard(g, result) {
it.resetControlParameters(object : CommonCallbacks.CompletionCallback<DJIError> {
override fun onResult(error: DJIError?) {
ctx.completion(result).onResult(error)
readSettings(it)
}
})
}
else -> return false
}
return true
@@ -40,10 +172,59 @@ class GimbalBridge(private val ctx: BridgeCtx) : SubBridge {
override fun bind(product: BaseProduct?) {
if (product !is Aircraft) return
product.gimbal?.setStateCallback { state ->
val g = product.gimbal ?: return
g.setStateCallback { state ->
val a = state.attitudeInDegrees
val values = mapOf("pitch" to a?.pitch, "roll" to a?.roll, "yaw" to a?.yaw)
val values = mapOf(
"pitch" to a?.pitch,
"roll" to a?.roll,
"yaw" to a?.yaw,
"mode" to state.mode?.name,
"yawRelative" to state.yawRelativeToAircraftHeading,
"pitchFineTune" to state.pitchFineTuneInDegrees,
"rollFineTune" to state.rollFineTuneInDegrees,
"yawFineTune" to state.yawFineTuneInDegrees,
"calibrating" to state.isCalibrating,
"calibrationProgress" to state.calibrationProgress,
"calibrationSuccessful" to state.isCalibrationSuccessful,
"pitchAtStop" to state.isPitchAtStop,
"yawAtStop" to state.isYawAtStop,
"motorOverloaded" to state.isMotorOverloaded,
)
ctx.mainHandler.post { ctx.hub.putFast(mapOf("gimbal" to values)) }
}
readSettings(g)
// Capabilities are empty until the gimbal has handshaken; read again then.
ctx.mainHandler.postDelayed({ if (gimbal() === g) readSettings(g) }, 3000)
}
private fun readSettings(g: Gimbal) {
for ((id, s) in settings) readSetting(g, id, s)
}
private fun readSetting(g: Gimbal, id: String, s: Setting) {
if (s.capability != null) {
val caps = try {
g.capabilities
} catch (_: Throwable) {
null
}
if (caps?.get(s.capability)?.isSupported != true) return
}
try {
s.read(g, object : CommonCallbacks.CompletionCallbackWith<Any> {
override fun onSuccess(value: Any) {
val v = KeyValues.toDart(value)
ctx.mainHandler.post { ctx.hub.patchSlow("gimbalSettings", mapOf(id to v)) }
}
override fun onFailure(error: DJIError) {}
})
} catch (_: Throwable) {
// Not implemented for this gimbal: the setting stays absent.
}
}
private inline fun <reified T : Enum<T>> enumByName(name: String?): T? =
enumValues<T>().firstOrNull { it.name == name }
}
@@ -223,6 +223,15 @@ class ProductProfileBridge(private val ctx: BridgeCtx) : SubBridge {
"yawAdjustable" to supported(CapabilityKey.ADJUST_YAW),
"rollAdjustable" to supported(CapabilityKey.ADJUST_ROLL),
"pitchRangeExtensionSupported" to supported(CapabilityKey.PITCH_RANGE_EXTENSION),
// The controller tuning ranges behind the gimbal settings' advanced rows (P5).
"controller" to mapOf(
"pitchSmoothing" to range(CapabilityKey.PITCH_CONTROLLER_SMOOTHING_FACTOR),
"yawSmoothing" to range(CapabilityKey.YAW_CONTROLLER_SMOOTHING_FACTOR),
"pitchMaxSpeed" to range(CapabilityKey.PITCH_CONTROLLER_MAX_SPEED),
"yawMaxSpeed" to range(CapabilityKey.YAW_CONTROLLER_MAX_SPEED),
"pitchSpeedCoefficient" to range(CapabilityKey.PITCH_CONTROLLER_SPEED_COEFFICIENT),
"yawSpeedCoefficient" to range(CapabilityKey.YAW_CONTROLLER_SPEED_COEFFICIENT),
).filterValues { it != null },
)
}
+32
View File
@@ -197,9 +197,41 @@ class DjiService {
Future<void> restoreCameraDefaults() => _methods.invokeMethod<void>('restoreCameraDefaults');
// ── Gimbal ──────────────────────────────────────────────────────────────────
/// Absolute pitch in degrees (the pitch slider).
Future<void> rotateGimbalPitch(double deg) =>
_methods.invokeMethod<void>('rotateGimbalPitch', <String, dynamic>{'pitch': deg});
/// Speed-mode rotation in °/s (drag on the video). The gimbal only keeps
/// moving while this is repeated (10 Hz); send zero to stop. A null axis is
/// left alone.
Future<void> rotateGimbalSpeed({double? pitch, double? yaw}) =>
_methods.invokeMethod<void>('rotateGimbalSpeed', <String, dynamic>{
if (pitch != null) 'pitch': pitch,
if (yaw != null) 'yaw': yaw,
});
/// mode: `YAW_FOLLOW` | `FPV` | `FREE`
Future<void> setGimbalMode(String mode) =>
_methods.invokeMethod<void>('setGimbalMode', <String, dynamic>{'mode': mode});
/// Pitch and yaw back to centre.
Future<void> resetGimbal() => _methods.invokeMethod<void>('resetGimbal');
Future<void> resetGimbalYaw() => _methods.invokeMethod<void>('resetGimbalYaw');
/// Nudges the gimbal's [axis] (`roll` | `pitch` | `yaw`) trim by [degrees].
Future<void> fineTuneGimbal(String axis, double degrees) =>
_methods.invokeMethod<void>('fineTuneGimbal', <String, dynamic>{'axis': axis, 'degrees': degrees});
/// Auto-calibration; `telemetry.gimbal.calibrating` / `calibrationProgress` follow it.
Future<void> startGimbalCalibration() => _methods.invokeMethod<void>('startGimbalCalibration');
/// One `gimbalSettings` value by id (`pitchExtension`, `yawFollow`,
/// `pitchSmoothing`, `yawMaxSpeed`, …) — see the table in `GimbalBridge.kt`.
Future<void> setGimbalSetting(String id, Object value) =>
_methods.invokeMethod<void>('setGimbalSetting', <String, dynamic>{'id': id, 'value': value});
/// The controller tuning values back to their defaults.
Future<void> resetGimbalControlParameters() => _methods.invokeMethod<void>('resetGimbalControlParameters');
// ── Missions ────────────────────────────────────────────────────────────────
/// points: [{lat, lon, altitude}], finishAction: NO_ACTION|GO_HOME|AUTO_LAND|GO_FIRST_WAYPOINT
+138
View File
@@ -0,0 +1,138 @@
import 'dart:math' as math;
import 'dart:ui' show Offset;
import 'product_profile.dart';
import 'state/aircraft_state.dart';
import 'state/gimbal_state.dart';
/// Pitch / yaw speed in °/s for `Gimbal.rotate` in speed mode; [yaw] is null
/// when the gimbal does not pan, so that axis is left alone.
class GimbalSpeed {
const GimbalSpeed(this.pitch, this.yaw);
static const GimbalSpeed zero = GimbalSpeed(0, 0);
final double pitch;
final double? yaw;
bool get isZero => pitch == 0 && (yaw ?? 0) == 0;
@override
bool operator ==(Object other) => other is GimbalSpeed && other.pitch == pitch && other.yaw == yaw;
@override
int get hashCode => Object.hash(pitch, yaw);
@override
String toString() => 'GimbalSpeed(${pitch.toStringAsFixed(1)}, ${yaw?.toStringAsFixed(1)})';
}
/// Press-and-drag on the live view as a gimbal joystick (GO 4
/// `fpv_gimbal_control`, plan P5): the press point is the centre, and how far
/// the finger has moved from it sets the speed — up tilts the camera up, right
/// pans right. A small dead zone keeps a resting finger still, and the curve is
/// squared so short drags give fine control.
abstract final class GimbalJoystick {
/// Finger travel (logical px) for full speed.
static const double reach = 120;
/// Finger travel that still means "hold".
static const double deadZone = 8;
/// Full-deflection speed, °/s.
static const double maxSpeed = 30;
/// The speed for a finger [delta] away from the press point.
static GimbalSpeed speedFor(Offset delta, {required bool yaw}) =>
GimbalSpeed(_axis(-delta.dy), yaw ? _axis(delta.dx) : null);
static double _axis(double d) {
final double mag = d.abs();
if (mag <= deadZone) return 0;
final double t = ((mag - deadZone) / (reach - deadZone)).clamp(0.0, 1.0);
final double v = maxSpeed * t * t;
return d < 0 ? -v : v;
}
/// The finger offset clamped to the joystick ring, for drawing the knob.
static Offset knob(Offset delta) {
final double d = delta.distance;
return d <= reach ? delta : delta * (reach / d);
}
}
/// A gimbal mode the pilot can pick.
class GimbalModeOption {
const GimbalModeOption(this.value, this.label, this.hint);
final String value;
final String label;
final String hint;
}
abstract final class GimbalModes {
static const GimbalModeOption follow =
GimbalModeOption('YAW_FOLLOW', 'Follow', 'Horizon stays level; the camera turns with the aircraft.');
static const GimbalModeOption fpv =
GimbalModeOption('FPV', 'FPV', 'The camera rolls with the aircraft, like a cockpit view.');
static const GimbalModeOption free =
GimbalModeOption('FREE', 'Free', 'The camera holds its heading while the aircraft turns.');
/// Follow and FPV on every aircraft gimbal; Free only where the gimbal pans
/// on its own (Inspire-class), since it needs an independent yaw axis.
static List<GimbalModeOption> optionsFor(GimbalProfile? g) =>
<GimbalModeOption>[follow, fpv, if (g?.yawAdjustable == true) free];
static String label(String? mode) => switch (mode) {
'YAW_FOLLOW' => follow.label,
'FPV' => fpv.label,
'FREE' => free.label,
_ => '—',
};
}
/// One controller tuning value of the gimbal sheet's Advanced section,
/// bound to a `gimbalSettings` id and the profile's range for it.
class GimbalTuning {
const GimbalTuning(this.id, this.label);
final String id;
final String label;
/// Steps of about a twentieth of the range, so − / + gets across it quickly.
static int stepFor(AxisRange r) => math.max(1, ((r.max - r.min) / 20).round());
static const List<GimbalTuning> all = <GimbalTuning>[
GimbalTuning('pitchMaxSpeed', 'Max pitch speed'),
GimbalTuning('pitchSmoothing', 'Pitch smoothness'),
GimbalTuning('pitchSpeedCoefficient', 'Pitch stick sensitivity'),
GimbalTuning('yawMaxSpeed', 'Max yaw speed'),
GimbalTuning('yawSmoothing', 'Yaw smoothness'),
GimbalTuning('yawSpeedCoefficient', 'Yaw stick sensitivity'),
];
/// The rows this gimbal has a range for; all of them as a preview when
/// nothing is connected.
static List<(GimbalTuning, AxisRange)> rowsFor(ProductProfile p) => <(GimbalTuning, AxisRange)>[
for (final GimbalTuning t in all)
if (!p.connected)
(t, const AxisRange(0, 100))
else if (p.gimbal?.controller[t.id] case final AxisRange r)
(t, r),
];
}
/// The one-tap gimbal actions of the sheet and when they are refused.
abstract final class GimbalActions {
/// Fine-tune nudge per tap, degrees (GO 4's Adjust Gimbal step).
static const double fineTuneStep = 0.1;
/// The pitch for "face down": straight down, or as far as this gimbal goes.
static double faceDownPitch(AxisRange? pitch) => math.max(-90, pitch?.min ?? -90);
/// Auto-calibration needs the aircraft still on the ground with the motors
/// off, and is pointless while one is already running.
static String? calibrationBlocked(AircraftState a, GimbalState g) {
if (a.isFlying == true || a.motorsOn == true) return 'Land and stop the motors first';
if (g.calibrating) return 'Calibrating…';
return null;
}
}
+16 -1
View File
@@ -70,10 +70,20 @@ abstract final class HudFeatures {
static final HudFeature gimbalPitch = HudFeature(
'gimbal.pitch', 'Gimbal pitch slider', 'Gimbal', (ProductProfile p) => p.gimbal?.pitchAdjustable == true);
static final HudFeature gimbalYaw = HudFeature(
'gimbal.yaw', 'Gimbal yaw drag', 'Gimbal', (ProductProfile p) => p.gimbal?.yawAdjustable == true);
'gimbal.yaw', 'Gimbal yaw (drag, recenter, Free mode)', 'Gimbal', (ProductProfile p) => p.gimbal?.yawAdjustable == true);
static final HudFeature gimbalPitchExtension = HudFeature(
'gimbal.pitchExtension', 'Extend pitch to +30°', 'Gimbal',
(ProductProfile p) => p.gimbal?.pitchRangeExtensionSupported == true);
static final HudFeature gimbalDrag = HudFeature(
'gimbal.drag', 'Drag on video to aim', 'Gimbal', (ProductProfile p) => p.gimbal?.pitchAdjustable == true);
static final HudFeature gimbalSettings = HudFeature(
'gimbal.settings', 'Gimbal settings (mode, recenter)', 'Gimbal', (ProductProfile p) => p.hasGimbal);
static final HudFeature gimbalRollAdjust = HudFeature(
'gimbal.rollAdjust', 'Adjust gimbal roll', 'Gimbal', (ProductProfile p) => p.hasGimbal);
static final HudFeature gimbalCalibration = HudFeature(
'gimbal.calibration', 'Auto calibrate gimbal', 'Gimbal', (ProductProfile p) => p.hasGimbal);
static final HudFeature gimbalAdvanced = HudFeature('gimbal.advanced', 'Gimbal speed / smoothness', 'Gimbal',
(ProductProfile p) => p.gimbal?.controller.isNotEmpty == true);
// ── Vision / control ──────────────────────────────────────────────────────
static final HudFeature vpsReadout = HudFeature(
@@ -116,6 +126,11 @@ abstract final class HudFeatures {
gimbalPitch,
gimbalYaw,
gimbalPitchExtension,
gimbalDrag,
gimbalSettings,
gimbalRollAdjust,
gimbalCalibration,
gimbalAdvanced,
vpsReadout,
visionRadar,
virtualSticks,
+11
View File
@@ -160,6 +160,7 @@ class GimbalProfile {
this.yawAdjustable = false,
this.rollAdjustable = false,
this.pitchRangeExtensionSupported = false,
this.controller = const <String, AxisRange>{},
});
/// A gimbal whose capability map has not arrived yet (`capabilitiesKnown`
@@ -174,6 +175,11 @@ class GimbalProfile {
yawAdjustable: m['yawAdjustable'] as bool? ?? false,
rollAdjustable: m['rollAdjustable'] as bool? ?? false,
pitchRangeExtensionSupported: m['pitchRangeExtensionSupported'] as bool? ?? false,
controller: <String, AxisRange>{
if (m['controller'] case final Map<dynamic, dynamic> c)
for (final MapEntry<dynamic, dynamic> e in c.entries)
if (AxisRange.fromMap(e.value) case final AxisRange r) '${e.key}': r,
},
);
final int index;
@@ -186,6 +192,11 @@ class GimbalProfile {
/// The +30° upward extension (Phantom 4 / Mavic Pro "Extend gimbal pitch").
final bool pitchRangeExtensionSupported;
/// Ranges of the controller tuning values this gimbal supports, keyed by
/// their `gimbalSettings` id (`pitchSmoothing`, `yawMaxSpeed`,
/// `pitchSpeedCoefficient`, …). Empty on gimbals without advanced settings.
final Map<String, AxisRange> controller;
}
class BatteryProfile {
+8 -42
View File
@@ -1,6 +1,7 @@
import 'package:flutter/foundation.dart';
import 'parse.dart';
import 'settings_overlay.dart';
/// The camera's storage (SD card, or internal storage on cameras that have it).
@immutable
@@ -145,7 +146,7 @@ class ZoomInfo {
/// the watched camera settings. Recording and exposure arrive as their own
/// `camera` / `exposure` events; storage and settings as the `storage` and
/// `cameraSettings` groups of the telemetry tick (≤2 Hz).
class CameraState extends ChangeNotifier {
class CameraState extends ChangeNotifier with SettingsOverlay {
bool isRecording = false;
int recordSeconds = 0;
@@ -189,25 +190,10 @@ class CameraState extends ChangeNotifier {
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];
// [settings] ([SettingsOverlay]) is the whole `cameraSettings` group —
// every id of `CameraBridge.kt`'s settings table plus the composite values —
// overlaid with the camera menu's pending writes. The menu's rows read it
// through [setting].
/// `PROGRAM` / `SHUTTER_PRIORITY` / `APERTURE_PRIORITY` / `MANUAL`.
String? get exposureMode => asString(settings['exposureMode']);
@@ -251,22 +237,6 @@ class CameraState extends ChangeNotifier {
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) {
@@ -295,10 +265,7 @@ class CameraState extends ChangeNotifier {
this.storage = storage == null ? null : StorageInfo.fromMap(storage);
this.ssd = ssd == null ? null : SsdInfo.fromMap(ssd);
thermalSpot = finite(thermal?['spotTemperature']);
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;
replaceReported(settings ?? const <String, dynamic>{});
updatedAt = DateTime.now();
notifyListeners();
}
@@ -309,8 +276,7 @@ class CameraState extends ChangeNotifier {
shootingSingle = shootingInterval = shootingBurst = shootingPanorama = false;
panoramaProgress = 0;
storingPhoto = overheating = hasError = false;
_reported = const <String, dynamic>{};
_pending.clear();
clearSettings();
histogram.value = null;
mode = iso = shutter = aperture = ev = null;
storage = null;
+66 -3
View File
@@ -1,25 +1,88 @@
import 'package:flutter/foundation.dart';
import 'parse.dart';
import 'settings_overlay.dart';
/// Gimbal attitude in degrees. Fed from the `gimbal` group of the telemetry tick.
class GimbalState extends ChangeNotifier {
/// Gimbal attitude, mode and status, fed from the `gimbal` group of the
/// telemetry tick; the getter-only settings (pitch extension, yaw follow,
/// controller tuning) from the `gimbalSettings` group, overlaid with the
/// gimbal sheet's pending writes ([SettingsOverlay]).
class GimbalState extends ChangeNotifier with SettingsOverlay {
double? pitch; // −90..+30-ish
double? roll;
double? yaw;
/// `YAW_FOLLOW` / `FPV` / `FREE`.
String? mode;
/// Yaw relative to the aircraft's nose, for gimbals that pan.
double? yawRelative;
double? pitchFineTune;
double? rollFineTune;
double? yawFineTune;
bool calibrating = false;
int? calibrationProgress; // 0..100
bool? calibrationSuccessful;
/// How the last calibration seen from start to end went; null until one ends.
bool? lastCalibrationOk;
bool pitchAtStop = false;
bool yawAtStop = false;
bool motorOverloaded = false;
DateTime? updatedAt;
// The `gimbalSettings` group, as the sheet shows it (pending writes included).
bool? get pitchExtension => asBool(settings['pitchExtension']);
bool? get yawFollow => asBool(settings['yawFollow']);
void apply(Map<String, dynamic> g) {
pitch = finite(g['pitch']);
roll = finite(g['roll']);
yaw = finite(g['yaw']);
mode = asString(g['mode']);
yawRelative = finite(g['yawRelative']);
pitchFineTune = finite(g['pitchFineTune']);
rollFineTune = finite(g['rollFineTune']);
yawFineTune = finite(g['yawFineTune']);
final bool wasCalibrating = calibrating;
calibrating = asBool(g['calibrating']) ?? false;
calibrationProgress = asInt(g['calibrationProgress']);
calibrationSuccessful = asBool(g['calibrationSuccessful']);
if (wasCalibrating && !calibrating) lastCalibrationOk = calibrationSuccessful;
pitchAtStop = asBool(g['pitchAtStop']) ?? false;
yawAtStop = asBool(g['yawAtStop']) ?? false;
motorOverloaded = asBool(g['motorOverloaded']) ?? false;
updatedAt = DateTime.now();
notifyListeners();
}
/// From the telemetry tick; an absent group means the gimbal reported none.
void applySettings(Map<String, dynamic>? s) {
replaceReported(s ?? const <String, dynamic>{});
notifyListeners();
}
/// Shows [mode] ahead of the gimbal's next state callback.
void setModeOptimistic(String mode) {
if (this.mode == mode) return;
this.mode = mode;
notifyListeners();
}
void clear() {
pitch = roll = yaw = null;
pitch = roll = yaw = yawRelative = null;
pitchFineTune = rollFineTune = yawFineTune = null;
mode = null;
calibrating = false;
calibrationProgress = null;
calibrationSuccessful = null;
lastCalibrationOk = null;
pitchAtStop = yawAtStop = motorOverloaded = false;
clearSettings();
updatedAt = null;
notifyListeners();
}
@@ -0,0 +1,53 @@
import 'package:flutter/foundation.dart';
import 'parse.dart';
/// Settings as a component reported them, overlaid with the HUD's optimistic
/// writes (plan P4, reused by the gimbal in P5).
///
/// A pending value stands until the component reports that setting changed,
/// the write fails, or the writer gives up waiting ([dropPending]) — so a
/// value the component silently refused snaps back.
mixin SettingsOverlay on ChangeNotifier {
Map<String, dynamic> _reported = const <String, dynamic>{};
final Map<String, Object?> _pending = <String, Object?>{};
/// What the component reported, overlaid with pending writes.
Map<String, dynamic> get settings => _pending.isEmpty ? _reported : <String, dynamic>{..._reported, ..._pending};
/// Only what the component itself reported.
Map<String, dynamic> get reportedSettings => _reported;
bool isPending(String id) => _pending.containsKey(id);
Object? setting(String id) => settings[id];
/// Shows [values] ahead of the component's read-back.
void setPending(Map<String, Object?> values) {
_pending.addAll(values);
notifyListeners();
}
/// Rolls [ids] back to what the component 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();
}
/// Takes a new report; a pending write is answered once its setting moves.
/// Does not notify — the caller does, with its other fields.
@protected
void replaceReported(Map<String, dynamic> reported) {
_pending.removeWhere((String id, _) => !deepEquals(_reported[id], reported[id]));
_reported = reported;
}
@protected
void clearSettings() {
_reported = const <String, dynamic>{};
_pending.clear();
}
}
+3 -2
View File
@@ -8,7 +8,7 @@ import 'state/parse.dart';
/// A `telemetry` event is the native [TelemetryHub]'s full snapshot: the flight
/// controller's flat keys plus one nested group per subsystem (`battery`,
/// `links`, `rc`, `storage`, `ssd`, `thermal`, `cameraSettings`, `gimbal`,
/// `vision`). It arrives at
/// `gimbalSettings`, `vision`). It arrives at
/// up to 10 Hz with the slow groups repeated unchanged between their 2 Hz
/// refreshes, so each group is compared with the last one seen and its
/// sub-model only notified when it actually moved. A group missing from the
@@ -20,7 +20,7 @@ class TelemetryReducer {
final Map<String, Object?> _lastGroups = <String, Object?>{};
static const List<String> _groups = <String>['battery', 'links', 'rc', 'storage', 'ssd', 'thermal', 'cameraSettings', 'gimbal', 'vision'];
static const List<String> _groups = <String>['battery', 'links', 'rc', 'storage', 'ssd', 'thermal', 'cameraSettings', 'gimbal', 'gimbalSettings', 'vision'];
/// Applies [event] if it is one of the telemetry types; returns whether it was.
bool apply(Map<String, dynamic> event) {
@@ -83,6 +83,7 @@ class TelemetryReducer {
final Map<String, dynamic>? g = asMap(t['gimbal']);
g == null ? model.gimbal.clear() : model.gimbal.apply(g);
}
if (changed.contains('gimbalSettings')) model.gimbal.applySettings(asMap(t['gimbalSettings']));
if (changed.contains('vision')) model.vision.apply(asMap(t['vision']));
}
}
+48 -9
View File
@@ -10,6 +10,7 @@ import '../../fly/safety_gate.dart';
import '../../theme.dart';
import '../album_page.dart';
import '../capture_modes_page.dart';
import '../pv_icons.dart';
import '../settings_menu_page.dart';
import 'bottom/attitude_ball.dart';
import 'bottom/minimap.dart';
@@ -21,11 +22,13 @@ import 'left/action_rail.dart';
import 'left/gimbal_slider.dart';
import 'overlays/alert_stack.dart';
import 'overlays/camera_touch_layer.dart';
import 'overlays/gimbal_drag_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 'panels/gimbal_sheet.dart';
import 'right/camera_column.dart';
import 'right/zoom_column.dart';
import 'top/camera_info_strip.dart';
@@ -52,6 +55,10 @@ import 'top/status_bar.dart';
/// 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.
///
/// Gimbal (plan P5): the pitch slider with its value bubble, press-and-drag
/// on the video as a speed joystick, and the gimbal settings sheet from the
/// button under the slider.
class FlyScreen extends StatefulWidget {
const FlyScreen({super.key, required this.model, required this.dji});
@@ -154,6 +161,23 @@ class _FlyScreenState extends State<FlyScreen> {
if (!_mapBig) ...<Widget>[
Positioned.fromRect(key: const ValueKey<String>('video-slot'), rect: full, child: video),
const Positioned.fill(child: VisualOverlays()),
// Under the camera touch layer, in the same gesture arena: drags aim
// the gimbal, taps and long presses still reach the camera.
Positioned.fill(
child: Requires(
HudFeatures.gimbalDrag,
child: ListenableBuilder(
listenable: _m,
builder: (BuildContext context, _) => GimbalDragLayer(
gimbal: _m.gimbal,
dji: _dji,
connected: _m.connected,
yaw: HudFeatures.gimbalYaw.enabledFor(ProductProfileScope.of(context)),
onError: _error,
),
),
),
),
Positioned.fill(
child: Requires(
HudFeatures.cameraControls,
@@ -240,17 +264,32 @@ class _FlyScreenState extends State<FlyScreen> {
left: 54,
top: 64,
bottom: _pipSize.height + 24,
child: Requires(
HudFeatures.gimbalPitch,
child: Builder(
builder: (BuildContext context) => GimbalSlider(
gimbal: _m.gimbal,
dji: _dji,
range: ProductProfileScope.of(context).gimbal?.pitch,
onError: _error,
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
Expanded(
child: Requires(
HudFeatures.gimbalPitch,
child: Builder(
builder: (BuildContext context) => GimbalSlider(
gimbal: _m.gimbal,
dji: _dji,
range: ProductProfileScope.of(context).gimbal?.pitch,
onError: _error,
),
),
),
),
),
const SizedBox(height: 8),
Requires(
HudFeatures.gimbalSettings,
child: GlassPill(
key: const ValueKey<String>('gimbal-settings-button'),
height: 28,
padding: const EdgeInsets.symmetric(horizontal: 6),
onTap: () => showGimbalSettings(context, model: _m, dji: _dji, onError: _error),
child: const PVIcon('gimbal', size: 16, color: Glass.ink),
),
),
]),
),
// ── Top bar, battery bar, camera info ─────────────────────────────────
Positioned(
+43 -14
View File
@@ -6,8 +6,10 @@ import '../../../fly/state/gimbal_state.dart';
import '../../../theme.dart';
import '../hud_kit.dart';
/// Vertical gimbal pitch slider over the profile's pitch range, with the live
/// pitch read back from the gimbal. P5 adds drag-on-video and the mode settings.
/// Vertical gimbal pitch slider over the profile's pitch range (GO 4
/// `fpv_gimbal_pitch_view`), with the live pitch read back from the gimbal
/// beside the thumb. While a finger is on it, a value bubble shows the pitch
/// being commanded, so the pilot sees the target before the gimbal gets there.
class GimbalSlider extends StatefulWidget {
const GimbalSlider({super.key, required this.gimbal, required this.dji, required this.range, required this.onError});
@@ -80,21 +82,12 @@ class _GimbalSliderState extends State<GimbalSlider> {
),
// The 0° mark, where the camera looks at the horizon.
Positioned(left: 2, top: _fracOf(0) * h - 0.5, child: Container(width: 12, height: 1, color: HudText.dim)),
// Centred on the pitch: the thumb row is 16 high, the bubble 22.
Positioned(
top: (frac * h - 8).clamp(0.0, h - 16),
top: _dragPitch == null ? (frac * h - 8).clamp(0.0, h - 16) : (frac * h - 11).clamp(-3.0, h - 19),
left: 0,
child: Row(children: <Widget>[
Container(
width: 16,
height: 16,
decoration: const BoxDecoration(
color: Glass.ink,
shape: BoxShape.circle,
boxShadow: <BoxShadow>[BoxShadow(color: Color(0x80000000), blurRadius: 3, offset: Offset(0, 1))],
),
),
const SizedBox(width: 3),
Text('${pitch.round()}°', style: HudText.monoSmall),
if (_dragPitch == null) ..._thumb(pitch) else ..._bubble(pitch),
]),
),
]),
@@ -104,4 +97,40 @@ class _GimbalSliderState extends State<GimbalSlider> {
);
});
}
List<Widget> _thumb(double pitch) => <Widget>[
Container(
width: 16,
height: 16,
decoration: const BoxDecoration(
color: Glass.ink,
shape: BoxShape.circle,
boxShadow: <BoxShadow>[BoxShadow(color: Color(0x80000000), blurRadius: 3, offset: Offset(0, 1))],
),
),
const SizedBox(width: 3),
Text('${pitch.round()}°', style: HudText.monoSmall),
];
/// The dragging thumb: accent-filled, with the target pitch in a pill.
List<Widget> _bubble(double pitch) => <Widget>[
Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: Glass.accent,
shape: BoxShape.circle,
border: Border.all(color: Glass.ink, width: 2),
),
),
const SizedBox(width: 5),
Container(
key: const ValueKey<String>('gimbal-bubble'),
height: 22,
padding: const EdgeInsets.symmetric(horizontal: 7),
decoration: BoxDecoration(color: Glass.accent, borderRadius: BorderRadius.circular(6)),
alignment: Alignment.center,
child: Text('${pitch.round()}°', style: HudText.value.copyWith(fontSize: 13)),
),
];
}
@@ -0,0 +1,201 @@
import 'dart:async';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import '../../../dji_service.dart';
import '../../../fly/gimbal_control.dart';
import '../../../fly/state/gimbal_state.dart';
import '../../../theme.dart';
import '../hud_kit.dart';
/// Press-and-drag on the live view aims the gimbal (GO 4 `fpv_gimbal_control`,
/// plan P5).
///
/// The press point becomes a joystick centre and the finger's offset from it a
/// pitch (and, on gimbals that pan, yaw) speed ([GimbalJoystick]). Speed mode
/// only moves the gimbal while it is refreshed, so the speed is re-sent every
/// [period] while the finger is down — whether or not it moved — and a zero
/// goes out on release.
///
/// It sits under [CameraTouchLayer] in the same gesture arena: a tap still
/// focuses and a still long press still meters; only a drag past the pan slop
/// lands here.
class GimbalDragLayer extends StatefulWidget {
const GimbalDragLayer({
super.key,
required this.gimbal,
required this.dji,
required this.connected,
required this.yaw,
required this.onError,
});
final GimbalState gimbal;
final DjiService dji;
final bool connected;
/// Whether this gimbal pans on its own, so horizontal drags drive yaw.
final bool yaw;
final void Function(String label, Object error) onError;
/// 10 Hz, the rate the plan and DJI's speed mode ask for.
static const Duration period = Duration(milliseconds: 100);
@override
State<GimbalDragLayer> createState() => _GimbalDragLayerState();
}
class _GimbalDragLayerState extends State<GimbalDragLayer> {
Offset? _origin;
Offset _delta = Offset.zero;
Timer? _ticker;
GimbalSpeed _lastSent = GimbalSpeed.zero;
bool _reported = false;
@override
void dispose() {
_ticker?.cancel();
if (!_lastSent.isZero) _send(_stop);
super.dispose();
}
GimbalSpeed get _speed => GimbalJoystick.speedFor(_delta, yaw: widget.yaw);
void _start(DragStartDetails d) {
_reported = false;
setState(() {
_origin = d.localPosition;
_delta = Offset.zero;
});
_ticker?.cancel();
_ticker = Timer.periodic(GimbalDragLayer.period, (_) => _tick());
}
void _update(DragUpdateDetails d) {
final Offset? o = _origin;
if (o == null) return;
setState(() => _delta = d.localPosition - o);
}
void _end() {
_ticker?.cancel();
_ticker = null;
if (mounted) {
setState(() {
_origin = null;
_delta = Offset.zero;
});
}
if (!_lastSent.isZero) _send(_stop);
}
/// Zero on the axes this gimbal drives.
GimbalSpeed get _stop => GimbalSpeed(0, widget.yaw ? 0 : null);
void _tick() {
final GimbalSpeed s = _speed;
// Holding inside the dead zone after a stop: nothing to refresh.
if (s.isZero && _lastSent.isZero) return;
_send(s);
}
void _send(GimbalSpeed s) {
_lastSent = s;
if (!widget.connected) return;
widget.dji.rotateGimbalSpeed(pitch: s.pitch, yaw: s.yaw).catchError((Object e) {
// Once per drag: a failing gimbal would otherwise raise ten snack bars a second.
if (_reported) return;
_reported = true;
widget.onError('Gimbal', e);
});
}
@override
Widget build(BuildContext context) {
final Offset? o = _origin;
return GestureDetector(
key: const ValueKey<String>('gimbal-drag'),
behavior: HitTestBehavior.translucent,
// The joystick centre is where the finger went down, not where it
// crossed the pan slop.
dragStartBehavior: DragStartBehavior.down,
onPanStart: _start,
onPanUpdate: _update,
onPanEnd: (_) => _end(),
onPanCancel: _end,
child: o == null
? const SizedBox.expand()
: Stack(clipBehavior: Clip.none, children: <Widget>[
Positioned.fill(
child: IgnorePointer(
child: CustomPaint(painter: _JoystickPainter(o, GimbalJoystick.knob(_delta), widget.yaw)),
),
),
Positioned(
left: o.dx + 18,
top: o.dy - GimbalJoystick.reach - 22,
child: IgnorePointer(
child: ListenableBuilder(
listenable: widget.gimbal,
builder: (BuildContext context, _) => GlassPill(
child: Text(
'P ${widget.gimbal.pitch?.round() ?? '—'}°'
'${widget.yaw ? ' Y ${widget.gimbal.yawRelative?.round() ?? '—'}°' : ''}',
style: HudText.mono,
),
),
),
),
),
]),
);
}
}
/// The centre mark, the reach ring, an arrow toward the finger and the knob.
class _JoystickPainter extends CustomPainter {
const _JoystickPainter(this.origin, this.knob, this.yaw);
final Offset origin;
final Offset knob;
final bool yaw;
@override
void paint(Canvas canvas, Size size) {
final Paint ring = Paint()
..color = const Color(0x66EAF0FA)
..style = PaintingStyle.stroke
..strokeWidth = 1.5;
canvas.drawCircle(origin, GimbalJoystick.reach, ring);
canvas.drawCircle(origin, GimbalJoystick.deadZone, ring);
// Pitch-only gimbals: a vertical track instead of a free ring feel.
if (!yaw) {
canvas.drawLine(origin.translate(0, -GimbalJoystick.reach), origin.translate(0, GimbalJoystick.reach), ring);
}
final Offset k = yaw ? knob : Offset(0, knob.dy);
final Offset end = origin + k;
if (k.distance > GimbalJoystick.deadZone) {
canvas.drawLine(
origin,
end,
Paint()
..color = Glass.accent
..strokeWidth = 3
..strokeCap = StrokeCap.round,
);
}
canvas.drawCircle(end, 18, Paint()..color = const Color(0x553D7BF0));
canvas.drawCircle(
end,
18,
Paint()
..color = Glass.accent
..style = PaintingStyle.stroke
..strokeWidth = 2,
);
}
@override
bool shouldRepaint(covariant _JoystickPainter old) => old.origin != origin || old.knob != knob || old.yaw != yaw;
}
@@ -46,7 +46,7 @@ class CameraRowTile extends StatelessWidget {
onError: onError,
),
),
SwitchRow() => _SwitchTile(
SwitchRow() => SwitchTile(
key: ValueKey<String>('row-${r.id}'),
label: r.label,
value: r.current(camera),
@@ -223,8 +223,9 @@ class _RowFrame extends StatelessWidget {
}
}
class _SwitchTile extends StatelessWidget {
const _SwitchTile({super.key, required this.label, required this.value, required this.onChanged});
/// An on / off row; a null [value] (not reported yet) shows as off.
class SwitchTile extends StatelessWidget {
const SwitchTile({super.key, required this.label, required this.value, required this.onChanged});
final String label;
final bool? value;
@@ -257,7 +258,7 @@ class PrefSwitchTile extends StatelessWidget {
final ValueChanged<bool> onChanged;
@override
Widget build(BuildContext context) => _SwitchTile(label: label, value: value, onChanged: onChanged);
Widget build(BuildContext context) => SwitchTile(label: label, value: value, onChanged: onChanged);
}
class _StepperTile extends StatelessWidget {
@@ -270,14 +271,53 @@ class _StepperTile extends StatelessWidget {
@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;
bool can(int delta) => v != null && (v + delta) >= row.min && (v + delta) <= row.max;
return StepperTile(
id: row.id,
label: row.label,
shown: v == null ? '—' : '${v > 0 && row.min < 0 ? '+' : ''}$v${row.unit.isEmpty ? '' : ' ${row.unit}'}',
canDown: can(-row.step),
canUp: can(row.step),
onStep: (int dir) => onChanged(v! + dir * row.step),
);
}
}
/// A − value + row. [onStep] gets −1 or +1; the caller owns the arithmetic,
/// so it fits integer settings and relative nudges (gimbal fine-tune) alike.
class StepperTile extends StatelessWidget {
const StepperTile({
super.key,
required this.id,
required this.label,
required this.shown,
required this.canDown,
required this.canUp,
required this.onStep,
this.hint,
this.pending = false,
});
/// Keys the buttons as `step-<id>-minus` / `step-<id>-plus`.
final String id;
final String label;
final String shown;
final bool canDown;
final bool canUp;
final ValueChanged<int> onStep;
final String? hint;
final bool pending;
@override
Widget build(BuildContext context) {
Widget step(String label, int dir) {
final bool enabled = dir > 0 ? canUp : canDown;
return Opacity(
opacity: enabled ? 1 : 0.35,
child: GestureDetector(
key: ValueKey<String>('step-${row.id}-${delta > 0 ? 'plus' : 'minus'}'),
key: ValueKey<String>('step-$id-${dir > 0 ? 'plus' : 'minus'}'),
behavior: HitTestBehavior.opaque,
onTap: enabled ? () => onChanged(v + delta) : null,
onTap: enabled ? () => onStep(dir) : null,
child: Container(
width: 30,
height: 28,
@@ -290,16 +330,20 @@ class _StepperTile extends StatelessWidget {
);
}
final String shown = v == null ? '—' : '${v > 0 && row.min < 0 ? '+' : ''}$v${row.unit.isEmpty ? '' : ' ${row.unit}'}';
return _RowFrame(
label: row.label,
label: label,
hint: hint,
trailing: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
step('−', -row.step),
step('−', -1),
SizedBox(
width: 66,
child: Text(shown, textAlign: TextAlign.center, style: HudText.mono.copyWith(color: Glass.accent, fontWeight: FontWeight.w700)),
child: Text(
shown,
textAlign: TextAlign.center,
style: HudText.mono.copyWith(color: pending ? HudText.dim : Glass.accent, fontWeight: FontWeight.w700),
),
),
step('+', row.step),
step('+', 1),
]),
);
}
@@ -337,19 +381,51 @@ class _ActionTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
final String? blocked = row.blockedReason(camera);
final Color color = row.danger ? HudColors.danger : Glass.accent;
return _RowFrame(
return ButtonTile(
id: row.id,
label: row.label,
button: row.button,
hint: blocked,
danger: row.danger,
onTap: blocked == null ? () => _run(context) : null,
);
}
}
/// A row with one outlined button, keyed `action-<id>`; a null [onTap] greys it out.
class ButtonTile extends StatelessWidget {
const ButtonTile({
super.key,
required this.id,
required this.label,
required this.button,
required this.onTap,
this.hint,
this.danger = false,
});
final String id;
final String label;
final String button;
final VoidCallback? onTap;
final String? hint;
final bool danger;
@override
Widget build(BuildContext context) {
final Color color = danger ? HudColors.danger : Glass.accent;
return _RowFrame(
label: label,
hint: hint,
trailing: Opacity(
opacity: blocked == null ? 1 : 0.35,
opacity: onTap != null ? 1 : 0.35,
child: GestureDetector(
key: ValueKey<String>('action-${row.id}'),
onTap: blocked == null ? () => _run(context) : null,
key: ValueKey<String>('action-$id'),
onTap: onTap,
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)),
child: Text(button, style: HudText.sans.copyWith(color: color)),
),
),
),
+6 -6
View File
@@ -1,18 +1,18 @@
import 'dart:async';
import '../../../fly/state/camera_state.dart';
import '../../../fly/state/settings_overlay.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
/// Writes one camera (or gimbal) setting the way every camera-menu row does
/// (plan P4): show [pending] at once, send, roll back with [onError] if the
/// component refuses, and drop the pending value after [cameraSettle] in any
/// case — by then it 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, {
SettingsOverlay camera, {
required String label,
required Map<String, Object?> pending,
required Future<void> Function() send,
+335
View File
@@ -0,0 +1,335 @@
import 'package:flutter/material.dart';
import '../../../dji_service.dart';
import '../../../flight_model.dart';
import '../../../fly/gimbal_control.dart';
import '../../../fly/hud_features.dart';
import '../../../fly/product_profile.dart';
import '../../../fly/state/gimbal_state.dart';
import '../../../fly/state/parse.dart';
import '../../../theme.dart';
import '../../pv_icons.dart';
import '../dialogs/slide_to_confirm.dart';
import '../hud_kit.dart';
import 'camera_menu/camera_rows.dart';
import 'camera_write.dart';
/// Opens the gimbal settings (plan P5) as a right-side glass sheet over the
/// live view, like the camera menu.
Future<void> showGimbalSettings(
BuildContext context, {
required FlightModel model,
required DjiService dji,
required void Function(String label, Object error) onError,
}) {
return showGeneralDialog<void>(
context: context,
barrierDismissible: true,
barrierLabel: 'Gimbal settings',
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.
child: ListenableBuilder(
listenable: model,
builder: (BuildContext context, Widget? child) => ProductProfileScope(profile: model.profile, child: child!),
child: _GimbalSheet(model: model, dji: dji, onError: onError),
),
),
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 _GimbalSheet extends StatelessWidget {
const _GimbalSheet({required this.model, required this.dji, required this.onError});
final FlightModel model;
final DjiService dji;
final void Function(String label, Object error) onError;
static const double width = 380;
@override
Widget build(BuildContext context) {
return Material(
color: const Color(0xF20B1426),
child: SizedBox(
width: width,
height: MediaQuery.sizeOf(context).height,
child: SafeArea(
left: false,
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
Padding(
padding: const EdgeInsets.fromLTRB(16, 6, 4, 6),
child: Row(children: <Widget>[
const PVIcon('gimbal', size: 16, color: Glass.ink),
const SizedBox(width: 8),
Expanded(child: Text('Gimbal', style: HudText.sans.copyWith(fontSize: 14, fontWeight: FontWeight.w700))),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const PVIcon('close', size: 18, color: HudText.dim),
),
]),
),
const Divider(height: 1, color: Glass.hairline),
Expanded(child: GimbalSettingsList(model: model, dji: dji, onError: onError)),
]),
),
),
);
}
}
/// The gimbal rows — mode, position, adjust, calibration, advanced — as one
/// scrolling list, so the P6 settings sheet's Gimbal tab can host it as is.
///
/// Rows hide per [HudFeature] on the connected profile, and the getter-only
/// settings also hide once the gimbal has reported and left them out.
class GimbalSettingsList extends StatelessWidget {
const GimbalSettingsList({super.key, required this.model, required this.dji, required this.onError});
final FlightModel model;
final DjiService dji;
final void Function(String label, Object error) onError;
GimbalState get _g => model.gimbal;
/// A getter-only setting is there unless the gimbal reported and omitted it.
bool _reported(ProductProfile p, String id) => !p.connected || _g.reportedSettings[id] != null;
Future<void> _run(String label, Future<void> Function() send) async {
try {
await send();
} catch (e) {
onError(label, e);
}
}
Future<void> _write(String label, String id, Object value) => writeCameraSetting(
_g,
label: label,
pending: <String, Object?>{id: value},
send: () => dji.setGimbalSetting(id, value),
onError: onError,
);
Future<void> _setMode(String mode) async {
final String? before = _g.mode;
_g.setModeOptimistic(mode);
try {
await dji.setGimbalMode(mode);
} catch (e) {
if (before != null) _g.setModeOptimistic(before);
onError('Gimbal mode', e);
}
}
Future<void> _calibrate(BuildContext context) async {
final SlideResult? ok = await showSlideToConfirm(
context,
const SlideConfirmSpec(
icon: 'gimbal',
title: 'Auto calibrate gimbal',
description: 'Put the aircraft on a flat, level surface with the motors off and the gimbal free to move. '
'Do not touch it until calibration finishes (about 30 seconds).',
sliderLabel: 'Slide to start',
),
);
if (ok == null) return;
await _run('Gimbal calibration', dji.startGimbalCalibration);
}
Future<void> _resetAdvanced(BuildContext context) async {
final SlideResult? ok = await showSlideToConfirm(
context,
const SlideConfirmSpec(
icon: 'gimbal',
title: 'Reset gimbal settings',
description: 'Speed, smoothness and sensitivity go back to their defaults.',
sliderLabel: 'Slide to reset',
),
);
if (ok == null) return;
await _run('Reset gimbal settings', dji.resetGimbalControlParameters);
}
static Widget _section(String title) => Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 4),
child: Text(title, style: HudText.label),
);
static String _signed(double v, {int decimals = 1}) => '${v > 0 ? '+' : ''}${v.toStringAsFixed(decimals)}°';
@override
Widget build(BuildContext context) {
final ProductProfile p = ProductProfileScope.of(context);
return ListenableBuilder(
listenable: Listenable.merge(<Listenable>[_g, model.aircraft]),
builder: (BuildContext context, _) => ListView(
key: const ValueKey<String>('gimbal-settings'),
padding: const EdgeInsets.only(bottom: 16),
children: <Widget>[
_status(p),
// ── Mode ──────────────────────────────────────────────────────────
ChoiceTile(
key: const ValueKey<String>('row-gimbalMode'),
label: 'Gimbal mode',
options: <(String, String)>[for (final GimbalModeOption o in GimbalModes.optionsFor(p.gimbal)) (o.value, o.label)],
selected: _g.mode,
onSelect: _setMode,
),
Requires(
HudFeatures.gimbalPitchExtension,
child: _reported(p, 'pitchExtension')
? SwitchTile(
key: const ValueKey<String>('row-pitchExtension'),
label: '30° extended upward',
value: _g.pitchExtension,
onChanged: (bool on) => _write('Extended pitch', 'pitchExtension', on),
)
: const SizedBox.shrink(),
),
Requires(
HudFeatures.gimbalYaw,
child: _reported(p, 'yawFollow')
? SwitchTile(
key: const ValueKey<String>('row-yawFollow'),
label: 'Yaw synchronous follow',
value: _g.yawFollow,
onChanged: (bool on) => _write('Yaw follow', 'yawFollow', on),
)
: const SizedBox.shrink(),
),
// ── Position ──────────────────────────────────────────────────────
_section('POSITION'),
ButtonTile(
id: 'recenter',
label: 'Recenter gimbal',
button: 'Recenter',
onTap: () => _run('Recenter gimbal', dji.resetGimbal),
),
ButtonTile(
id: 'faceDown',
label: 'Face down',
button: 'Down',
onTap: () => _run('Gimbal down', () => dji.rotateGimbalPitch(GimbalActions.faceDownPitch(p.gimbal?.pitch))),
),
Requires(
HudFeatures.gimbalYaw,
child: ButtonTile(
id: 'yawRecenter',
label: 'Yaw recenter',
button: 'Recenter',
onTap: () => _run('Yaw recenter', dji.resetGimbalYaw),
),
),
// ── Adjust ────────────────────────────────────────────────────────
Requires(
HudFeatures.gimbalRollAdjust,
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
_section('ADJUST'),
StepperTile(
id: 'rollFineTune',
label: 'Roll fine-tune',
hint: 'Level the horizon against the video',
shown: _g.rollFineTune == null ? '—' : _signed(_g.rollFineTune!),
canDown: true,
canUp: true,
onStep: (int dir) => _run('Roll adjust', () => dji.fineTuneGimbal('roll', dir * GimbalActions.fineTuneStep)),
),
]),
),
// ── Calibration ───────────────────────────────────────────────────
Requires(
HudFeatures.gimbalCalibration,
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
_section('CALIBRATION'),
_calibrationRow(context),
]),
),
// ── Advanced ──────────────────────────────────────────────────────
Requires(
HudFeatures.gimbalAdvanced,
whenDisconnected: false,
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
_section('ADVANCED'),
for (final (GimbalTuning t, AxisRange r) in GimbalTuning.rowsFor(p))
if (_g.reportedSettings[t.id] != null) _tuningRow(t, r),
ButtonTile(
id: 'resetAdvanced',
label: 'Reset all gimbal settings',
button: 'Reset',
danger: true,
onTap: () => _resetAdvanced(context),
),
]),
),
],
),
);
}
/// Live attitude, plus whatever the gimbal is complaining about.
Widget _status(ProductProfile p) {
final List<String> warnings = <String>[
if (_g.motorOverloaded) 'Motor overloaded — check for obstructions',
if (_g.pitchAtStop) 'Pitch at its limit',
if (_g.yawAtStop && (p.gimbal?.yawAdjustable ?? false)) 'Yaw at its limit',
];
String deg(double? v) => v == null ? '—' : '${v.round()}°';
return Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 4),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
Text(
'PITCH ${deg(_g.pitch)} ROLL ${deg(_g.roll)} YAW ${deg(_g.yawRelative ?? _g.yaw)}',
key: const ValueKey<String>('gimbal-attitude'),
style: HudText.mono,
),
for (final String w in warnings)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(w, style: HudText.sans.copyWith(color: HudColors.caution)),
),
]),
);
}
Widget _calibrationRow(BuildContext context) {
final String? blocked = GimbalActions.calibrationBlocked(model.aircraft, _g);
final String? hint = _g.calibrating
? 'Calibrating… ${_g.calibrationProgress ?? 0}%'
: blocked ??
switch (_g.lastCalibrationOk) {
true => 'Calibrated',
false => 'Calibration failed — retry on level ground',
null => null,
};
return ButtonTile(
id: 'calibrateGimbal',
label: 'Auto calibrate gimbal',
button: 'Calibrate',
hint: hint,
onTap: blocked == null ? () => _calibrate(context) : null,
);
}
Widget _tuningRow(GimbalTuning t, AxisRange r) {
final int? v = asInt(_g.setting(t.id));
final int step = GimbalTuning.stepFor(r);
final int lo = r.min.round(), hi = r.max.round();
return StepperTile(
key: ValueKey<String>('row-${t.id}'),
id: t.id,
label: t.label,
shown: v?.toString() ?? '—',
pending: _g.isPending(t.id),
canDown: v != null && v > lo,
canUp: v != null && v < hi,
onStep: (int dir) => _write(t.label, t.id, (v! + dir * step).clamp(lo, hi)),
);
}
}
+21
View File
@@ -92,4 +92,25 @@ class FakeDjiService extends DjiService {
Future<void> restoreCameraDefaults() => _record('restoreCameraDefaults');
@override
Future<void> setHistogramEnabled(bool on) => _record('setHistogramEnabled($on)');
// ── Gimbal (plan P5) ───────────────────────────────────────────────────────
@override
Future<void> rotateGimbalPitch(double deg) => _record('rotateGimbalPitch(${deg.round()})');
@override
Future<void> rotateGimbalSpeed({double? pitch, double? yaw}) =>
_record('rotateGimbalSpeed(${pitch?.toStringAsFixed(1)}, ${yaw?.toStringAsFixed(1)})');
@override
Future<void> setGimbalMode(String mode) => _record('setGimbalMode($mode)');
@override
Future<void> resetGimbal() => _record('resetGimbal');
@override
Future<void> resetGimbalYaw() => _record('resetGimbalYaw');
@override
Future<void> fineTuneGimbal(String axis, double degrees) => _record('fineTuneGimbal($axis, ${degrees.toStringAsFixed(1)})');
@override
Future<void> startGimbalCalibration() => _record('startGimbalCalibration');
@override
Future<void> setGimbalSetting(String id, Object value) => _record('setGimbalSetting($id, $value)');
@override
Future<void> resetGimbalControlParameters() => _record('resetGimbalControlParameters');
}
+321
View File
@@ -0,0 +1,321 @@
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/gimbal_control.dart';
import 'package:pilotvault_fly/fly/hud_features.dart';
import 'package:pilotvault_fly/fly/product_profile.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_write.dart';
import 'package:pilotvault_fly/ui/fly/panels/gimbal_sheet.dart';
import 'fake_dji.dart';
import 'profile_fixtures.dart';
import 'telemetry_fixtures.dart';
/// A connected model whose gimbal has reported [gimbal] on top of the fixture
/// tick's attitude, and [settings] as its `gimbalSettings` group.
FlightModel gimbalModel(
Map<String, dynamic> profile, {
Map<String, Object?> gimbal = const <String, Object?>{},
Map<String, Object?>? settings,
}) {
final FlightModel m = FlightModel()
..connected = true
..profile = ProductProfile.fromBridge(profile);
final Map<String, dynamic> t = telemetryTick();
t['gimbal'] = <Object?, Object?>{
...(t['gimbal'] as Map<Object?, Object?>),
'mode': 'YAW_FOLLOW',
'rollFineTune': 0.0,
'calibrating': false,
...gimbal,
};
if (settings != null) t['gimbalSettings'] = settings;
TelemetryReducer(m).apply(t);
return m;
}
void main() {
group('Joystick', () {
test('dead zone holds, up tilts up, full reach is full speed', () {
expect(GimbalJoystick.speedFor(const Offset(3, -5), yaw: true), GimbalSpeed.zero);
final GimbalSpeed up = GimbalJoystick.speedFor(const Offset(0, -GimbalJoystick.reach), yaw: false);
expect(up.pitch, GimbalJoystick.maxSpeed);
expect(up.yaw, isNull, reason: 'pitch-only gimbals leave yaw alone');
expect(GimbalJoystick.speedFor(const Offset(0, 500), yaw: false).pitch, -GimbalJoystick.maxSpeed, reason: 'clamped');
expect(GimbalJoystick.speedFor(const Offset(GimbalJoystick.reach, 0), yaw: true).yaw, GimbalJoystick.maxSpeed);
});
test('short drags are fine control (squared curve)', () {
final double half = GimbalJoystick.speedFor(const Offset(0, -(GimbalJoystick.deadZone + (GimbalJoystick.reach - GimbalJoystick.deadZone) / 2)), yaw: false).pitch;
expect(half, closeTo(GimbalJoystick.maxSpeed / 4, 0.01));
});
test('the knob stays on the ring', () {
expect(GimbalJoystick.knob(const Offset(300, 400)).distance, closeTo(GimbalJoystick.reach, 0.001));
expect(GimbalJoystick.knob(const Offset(10, 10)), const Offset(10, 10));
});
});
group('Modes, tuning and actions', () {
test('Free only on gimbals that pan', () {
List<String> modes(Map<String, dynamic> p) => <String>[for (final GimbalModeOption o in GimbalModes.optionsFor(ProductProfile.fromBridge(p).gimbal)) o.value];
expect(modes(phantom4), <String>['YAW_FOLLOW', 'FPV']);
expect(modes(inspire2X5s), <String>['YAW_FOLLOW', 'FPV', 'FREE']);
expect(GimbalModes.label('FPV'), 'FPV');
expect(GimbalModes.label(null), '—');
});
test('advanced rows come from the profile ranges', () {
expect(GimbalTuning.rowsFor(ProductProfile.fromBridge(phantom4)), isEmpty);
expect(HudFeatures.gimbalAdvanced.enabledFor(ProductProfile.fromBridge(phantom4)), isFalse);
final ProductProfile i2 = ProductProfile.fromBridge(inspire2X5s);
expect(HudFeatures.gimbalAdvanced.enabledFor(i2), isTrue);
expect(<String>[for (final (GimbalTuning t, _) in GimbalTuning.rowsFor(i2)) t.id], <String>['pitchMaxSpeed', 'pitchSmoothing', 'yawMaxSpeed']);
expect(GimbalTuning.stepFor(const AxisRange(1, 100)), 5);
expect(GimbalTuning.stepFor(const AxisRange(0, 10)), 1);
});
test('face down goes as far as the gimbal does', () {
expect(GimbalActions.faceDownPitch(const AxisRange(-90, 30)), -90);
expect(GimbalActions.faceDownPitch(const AxisRange(-60, 0)), -60);
expect(GimbalActions.faceDownPitch(null), -90);
});
test('calibration only on the ground with motors off', () {
final FlightModel m = gimbalModel(phantom4);
expect(GimbalActions.calibrationBlocked(m.aircraft, m.gimbal), isNotNull, reason: 'the fixture is flying');
m.aircraft
..isFlying = false
..motorsOn = false;
expect(GimbalActions.calibrationBlocked(m.aircraft, m.gimbal), isNull);
});
});
group('State', () {
test('telemetry carries mode, trim, calibration and the settings group', () {
final FlightModel m = gimbalModel(inspire2X5s,
gimbal: <String, Object?>{'mode': 'FPV', 'rollFineTune': 0.3, 'calibrating': true, 'calibrationProgress': 40},
settings: <String, Object?>{'pitchExtension': true, 'pitchMaxSpeed': 50});
expect(m.gimbal.mode, 'FPV');
expect(m.gimbal.rollFineTune, 0.3);
expect(m.gimbal.calibrationProgress, 40);
expect(m.gimbal.pitchExtension, isTrue);
expect(m.gimbal.setting('pitchMaxSpeed'), 50);
});
test('the end of a calibration is remembered', () {
final FlightModel m = gimbalModel(phantom4, gimbal: <String, Object?>{'calibrating': true});
m.gimbal.apply(<String, dynamic>{'calibrating': false, 'calibrationSuccessful': true});
expect(m.gimbal.lastCalibrationOk, isTrue);
});
test('a pending write is answered by the report', () {
final FlightModel m = gimbalModel(phantom4, settings: <String, Object?>{'pitchExtension': false});
m.gimbal.setPending(<String, Object?>{'pitchExtension': true});
expect(m.gimbal.pitchExtension, isTrue);
m.gimbal.applySettings(<String, dynamic>{'pitchExtension': true});
expect(m.gimbal.isPending('pitchExtension'), isFalse);
expect(m.gimbal.pitchExtension, isTrue);
});
});
group('Widgets', () {
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();
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> openSheet(WidgetTester tester) async {
await tester.tap(find.byKey(const ValueKey<String>('gimbal-settings-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
}
Finder sheetList() => find.descendant(of: find.byType(GimbalSettingsList), matching: find.byType(Scrollable));
testWidgets('the slider shows a value bubble while dragging and commands the pitch', (WidgetTester tester) async {
final FakeDjiService dji = await pump(tester, gimbalModel(phantom4));
expect(find.byKey(const ValueKey<String>('gimbal-bubble')), findsNothing);
final Offset thumb = tester.getTopLeft(find.text('-30°')) + const Offset(-10, 8);
final TestGesture g = await tester.startGesture(thumb);
for (int i = 0; i < 4; i++) {
await g.moveBy(const Offset(0, 10));
await tester.pump(const Duration(milliseconds: 20));
}
expect(find.byKey(const ValueKey<String>('gimbal-bubble')), findsOneWidget);
await g.up();
await tester.pump();
expect(find.byKey(const ValueKey<String>('gimbal-bubble')), findsNothing);
expect(dji.calls.where((String c) => c.startsWith('rotateGimbalPitch')), isNotEmpty);
});
testWidgets('dragging the video sends speed at 10 Hz and a zero on release', (WidgetTester tester) async {
final FakeDjiService dji = await pump(tester, gimbalModel(phantom4));
final TestGesture g = await tester.startGesture(const Offset(422, 220));
await g.moveBy(const Offset(0, -20));
await g.moveBy(const Offset(0, -100));
await tester.pump();
await tester.pump(const Duration(milliseconds: 350));
final List<String> speeds = dji.calls.where((String c) => c.startsWith('rotateGimbalSpeed')).toList();
expect(speeds.length, 3, reason: 'one per 100 ms tick');
expect(speeds.first, startsWith('rotateGimbalSpeed('));
expect(speeds.first, endsWith(', null)'), reason: 'Phantom 4 does not pan');
expect(speeds.every((String c) => !c.contains('(0.0')), isTrue);
await g.up();
await tester.pump();
expect(dji.calls.last, 'rotateGimbalSpeed(0.0, null)');
expect(dji.calls, isNot(contains(startsWith('setFocusTarget'))), reason: 'a drag is not a tap');
expect(dji.calls, isNot(contains(startsWith('setSpotMeteringTarget'))));
await tester.pump(const Duration(milliseconds: 300));
expect(dji.calls.last, 'rotateGimbalSpeed(0.0, null)', reason: 'stops refreshing once released');
});
testWidgets('a tap on the video still meters, not the gimbal', (WidgetTester tester) async {
final FakeDjiService dji = await pump(tester, gimbalModel(phantom4));
await tester.tapAt(const Offset(422, 195));
await tester.pump();
expect(dji.calls, contains('setSpotMeteringTarget(0.50, 0.50)'));
expect(dji.calls.where((String c) => c.startsWith('rotateGimbalSpeed')), isEmpty);
await tester.pump(cameraSettle);
});
testWidgets('the sheet sets the mode and recenters', (WidgetTester tester) async {
final FlightModel m = gimbalModel(phantom4);
final FakeDjiService dji = await pump(tester, m);
await openSheet(tester);
expect(find.text('Follow'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey<String>('row-gimbalMode')));
await tester.pump();
expect(find.byKey(const ValueKey<String>('opt-FREE')), findsNothing, reason: 'Phantom 4 has no yaw axis');
await tester.tap(find.byKey(const ValueKey<String>('opt-FPV')));
await tester.pump();
expect(dji.calls, contains('setGimbalMode(FPV)'));
expect(m.gimbal.mode, 'FPV');
await tester.tap(find.byKey(const ValueKey<String>('action-recenter')));
await tester.pump();
expect(dji.calls, contains('resetGimbal'));
await tester.tap(find.byKey(const ValueKey<String>('action-faceDown')));
await tester.pump();
expect(dji.calls, contains('rotateGimbalPitch(-90)'));
expect(find.byKey(const ValueKey<String>('action-yawRecenter')), findsNothing);
});
testWidgets('a refused mode change rolls back', (WidgetTester tester) async {
final FlightModel m = gimbalModel(phantom4);
final FakeDjiService dji = await pump(tester, m);
dji.failWith = PlatformException(code: 'DJI_ERROR', message: 'Not supported');
await openSheet(tester);
await tester.tap(find.byKey(const ValueKey<String>('row-gimbalMode')));
await tester.pump();
await tester.tap(find.byKey(const ValueKey<String>('opt-FPV')));
await tester.pump();
await tester.pump();
expect(m.gimbal.mode, 'YAW_FOLLOW');
expect(find.textContaining('Not supported'), findsOneWidget);
});
testWidgets('extended pitch writes through the overlay and snaps back unconfirmed', (WidgetTester tester) async {
final FlightModel m = gimbalModel(phantom4, settings: <String, Object?>{'pitchExtension': false});
final FakeDjiService dji = await pump(tester, m);
await openSheet(tester);
await tester.tap(find.byKey(const ValueKey<String>('row-pitchExtension')));
await tester.pump();
expect(dji.calls, contains('setGimbalSetting(pitchExtension, true)'));
expect(m.gimbal.pitchExtension, isTrue);
await tester.pump(cameraSettle);
expect(m.gimbal.pitchExtension, isFalse);
});
testWidgets('roll fine-tune nudges by 0.1°', (WidgetTester tester) async {
final FakeDjiService dji = await pump(tester, gimbalModel(phantom4));
await openSheet(tester);
final Finder plus = find.byKey(const ValueKey<String>('step-rollFineTune-plus'));
await tester.scrollUntilVisible(plus, 60, scrollable: sheetList());
await tester.ensureVisible(plus);
await tester.pump();
await tester.tap(plus);
await tester.pump();
await tester.tap(find.byKey(const ValueKey<String>('step-rollFineTune-minus')));
await tester.pump();
expect(dji.calls, containsAllInOrder(<String>['fineTuneGimbal(roll, 0.1)', 'fineTuneGimbal(roll, -0.1)']));
});
testWidgets('calibration is blocked in flight and slide-confirmed on the ground', (WidgetTester tester) async {
final FlightModel m = gimbalModel(phantom4);
final FakeDjiService dji = await pump(tester, m);
await openSheet(tester);
final Finder calibrate = find.byKey(const ValueKey<String>('action-calibrateGimbal'));
await tester.scrollUntilVisible(calibrate, 60, scrollable: sheetList());
await tester.ensureVisible(calibrate);
await tester.pump();
expect(find.text('Land and stop the motors first'), findsOneWidget);
await tester.tap(calibrate);
await tester.pump();
expect(find.byType(SlideConfirmDialog), findsNothing);
m.aircraft
..isFlying = false
..motorsOn = false
..notifyListeners();
await tester.pump();
await tester.tap(calibrate);
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(dji.calls, isNot(contains('startGimbalCalibration')));
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('startGimbalCalibration'));
});
testWidgets('advanced rows on a tunable gimbal step by its range', (WidgetTester tester) async {
final FlightModel m = gimbalModel(inspire2X5s, settings: <String, Object?>{'pitchMaxSpeed': 50, 'pitchSmoothing': 10});
final FakeDjiService dji = await pump(tester, m);
await openSheet(tester);
final Finder plus = find.byKey(const ValueKey<String>('step-pitchMaxSpeed-plus'));
await tester.scrollUntilVisible(plus, 60, scrollable: sheetList());
await tester.ensureVisible(plus);
await tester.pump();
expect(find.byKey(const ValueKey<String>('row-yawMaxSpeed')), findsNothing, reason: 'never reported');
await tester.tap(plus);
await tester.pump();
expect(dji.calls, contains('setGimbalSetting(pitchMaxSpeed, 55)'));
await tester.pump(cameraSettle);
});
testWidgets('a panning gimbal gets yaw recenter and yaw speed', (WidgetTester tester) async {
final FakeDjiService dji = await pump(tester, gimbalModel(inspire2X5s));
final TestGesture g = await tester.startGesture(const Offset(422, 220));
await g.moveBy(const Offset(60, 0));
await g.moveBy(const Offset(60, 0));
await tester.pump(const Duration(milliseconds: 150));
expect(dji.calls.firstWhere((String c) => c.startsWith('rotateGimbalSpeed')), 'rotateGimbalSpeed(0.0, 30.0)');
await g.up();
await tester.pump();
expect(dji.calls.last, 'rotateGimbalSpeed(0.0, 0.0)');
await openSheet(tester);
await tester.tap(find.byKey(const ValueKey<String>('action-yawRecenter')));
await tester.pump();
expect(dji.calls, contains('resetGimbalYaw'));
});
testWidgets('lays out without overflow on a small phone and a tablet, sheet open', (WidgetTester tester) async {
for (final Size s in const <Size>[Size(740, 340), Size(844, 390), Size(1280, 800)]) {
await pump(tester, gimbalModel(inspire2X5s, settings: <String, Object?>{'pitchMaxSpeed': 50, 'yawFollow': true}), size: s);
expect(tester.takeException(), isNull, reason: '$s');
await openSheet(tester);
expect(tester.takeException(), isNull, reason: '$s sheet');
}
});
});
}
+9 -2
View File
@@ -39,7 +39,7 @@ Map<String, dynamic> _camera({
'colors': <String>['NONE', 'D_LOG'],
};
Map<String, dynamic> _gimbal({bool yaw = false, double pitchMax = 0}) => <String, dynamic>{
Map<String, dynamic> _gimbal({bool yaw = false, double pitchMax = 0, bool tuning = false}) => <String, dynamic>{
'index': 0,
'capabilitiesKnown': true,
'pitch': <String, dynamic>{'min': -90.0, 'max': pitchMax},
@@ -49,6 +49,13 @@ Map<String, dynamic> _gimbal({bool yaw = false, double pitchMax = 0}) => <String
'yawAdjustable': yaw,
'rollAdjustable': false,
'pitchRangeExtensionSupported': pitchMax > 0,
'controller': tuning
? <String, dynamic>{
'pitchMaxSpeed': <String, dynamic>{'min': 1.0, 'max': 100.0},
'pitchSmoothing': <String, dynamic>{'min': 0.0, 'max': 30.0},
'yawMaxSpeed': <String, dynamic>{'min': 1.0, 'max': 100.0},
}
: <String, dynamic>{},
};
Map<String, dynamic> _fc({bool assistant = true, bool sticks = true}) => <String, dynamic>{
@@ -106,7 +113,7 @@ final Map<String, dynamic> inspire2X5s = <String, dynamic>{
'cameras': <dynamic>[
_camera(name: 'Zenmuse X5S', aperture: true, ssd: true, apertures: <String>['f/1.7', 'f/2.8', 'f/5.6', 'f/16']),
],
'gimbals': <dynamic>[_gimbal(yaw: true, pitchMax: 30)],
'gimbals': <dynamic>[_gimbal(yaw: true, pitchMax: 30, tuning: true)],
'link': 'lightbridge',
'flightController': _fc(),
'remoteController': <String, dynamic>{'masterSlaveSupported': true},