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:
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 },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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']));
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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},
|
||||
|
||||
Reference in new issue
Block a user