Give the Fly App GO 4-style intelligent modes and a Waypoints editor

P7a of the GO 4-parity plan. The rail's GRID button becomes MODES, which
opens a picker of the product's intelligent flight modes with first-use
terms; Waypoints is the first one built.

Waypoints opens on the big map: tap to add waypoints or POIs, long-press
to drag, "+" to insert on a leg, and a point editor for altitude, speed,
heading (route / custom / POI), gimbal (set or aim at POI), camera action
and hover, and turn radius, with a swipe-to-adjust readout. Route settings,
a local mission library and undo sit in a tool column. Start runs the
SafetyGate's pre-start check in the slide-to-confirm dialog, then uploads
and starts; a run panel shows upload and flight progress with Pause,
Resume and Stop.

MissionBridge now listens to the waypoint operator and builds the full V1
mission (per-waypoint headings, turn direction, gimbal pitch, actions).
Not yet run on a simulator or aircraft.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-20 16:22:53 +02:00
1 parent a892bfb209
commit 43a66df8df
22 files changed
+4276 -83

No files matched your search

@@ -1,22 +1,49 @@
package com.pilotvault.flyapp
import android.graphics.RectF
import dji.common.error.DJIError
import dji.common.mission.activetrack.ActiveTrackMission
import dji.common.mission.activetrack.ActiveTrackMode
import dji.common.mission.activetrack.QuickShotMode
import dji.common.mission.waypoint.Waypoint
import dji.common.mission.waypoint.WaypointAction
import dji.common.mission.waypoint.WaypointActionType
import dji.common.mission.waypoint.WaypointMission
import dji.common.mission.waypoint.WaypointMissionDownloadEvent
import dji.common.mission.waypoint.WaypointMissionExecutionEvent
import dji.common.mission.waypoint.WaypointMissionFinishedAction
import dji.common.mission.waypoint.WaypointMissionFlightPathMode
import dji.common.mission.waypoint.WaypointMissionGotoWaypointMode
import dji.common.mission.waypoint.WaypointMissionHeadingMode
import dji.common.mission.waypoint.WaypointMissionUploadEvent
import dji.common.mission.waypoint.WaypointTurnMode
import dji.common.model.LocationCoordinate2D
import dji.sdk.base.BaseProduct
import dji.sdk.mission.activetrack.ActiveTrackOperator
import dji.sdk.mission.waypoint.WaypointMissionOperator
import dji.sdk.mission.waypoint.WaypointMissionOperatorListener
import dji.sdk.sdkmanager.DJISDKManager
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
/**
* Intelligent-flight & waypoint missions.
* Intelligent-flight & waypoint missions (plan P7).
*
* - Waypoint route (from the Map screen) → [WaypointMissionOperator].
* - Waypoints → [WaypointMissionOperator]. `uploadWaypointMission` takes the
* resolved mission the Dart editor builds (`WaypointMissionSpec.toBridge`):
*
* ```
* {waypoints: [{lat, lon, altitude, heading?, turnMode, speed, cornerRadius,
* gimbalPitch?, actions: [{type: WaypointActionType, param}]}],
* autoFlightSpeed, maxFlightSpeed, finishedAction, headingMode,
* flightPathMode, gotoFirstWaypointMode, exitMissionOnRCSignalLost,
* gimbalPitchRotation, repeatTimes, pointOfInterest?: {lat, lon}}
* ```
*
* The operator's listener is published as `mission` events with
* `operator: 'waypoint'`: `state` (WaypointMissionState), `upload`
* {index, total, summary}, `execution` {target, reached, executeState,
* total}, `event` (`start` / `finish`) and `error`.
* - ActiveTrack subject tracking → [ActiveTrackOperator].
* - DJI-Fly QuickShots (Dronie/Rocket/Circle/Helix/Boomerang/Asteroid) → an
* ActiveTrack mission in [ActiveTrackMode.QUICK_SHOT] with the matching
@@ -30,13 +57,88 @@ class MissionBridge(private val ctx: BridgeCtx) : SubBridge {
private fun trackOp(): ActiveTrackOperator? =
DJISDKManager.getInstance().missionControl?.activeTrackOperator
private var listenedOp: WaypointMissionOperator? = null
private val waypointListener = object : WaypointMissionOperatorListener {
override fun onDownloadUpdate(event: WaypointMissionDownloadEvent) {}
override fun onUploadUpdate(event: WaypointMissionUploadEvent) {
val p = event.progress
emitWaypoint(
state = event.currentState?.name,
upload = p?.let { mapOf("index" to it.uploadedWaypointIndex, "total" to it.totalWaypointCount, "summary" to it.isSummaryUploaded) },
error = event.error,
)
}
override fun onExecutionUpdate(event: WaypointMissionExecutionEvent) {
val p = event.progress
emitWaypoint(
state = event.currentState?.name,
execution = p?.let {
mapOf(
"target" to it.targetWaypointIndex,
"reached" to it.isWaypointReached,
"executeState" to it.executeState?.name,
"total" to it.totalWaypointCount,
)
},
error = event.error,
)
}
override fun onExecutionStart() {
emitWaypoint(state = waypointOp()?.currentState?.name, event = "start")
}
override fun onExecutionFinish(error: DJIError?) {
emitWaypoint(state = waypointOp()?.currentState?.name, event = "finish", error = error)
}
}
private fun emitWaypoint(
state: String?,
upload: Map<String, Any?>? = null,
execution: Map<String, Any?>? = null,
event: String? = null,
error: DJIError? = null,
) {
val m = linkedMapOf<String, Any?>("type" to "mission", "operator" to "waypoint")
if (state != null) m["state"] = state
if (upload != null) m["upload"] = upload
if (execution != null) m["execution"] = execution
if (event != null) m["event"] = event
if (error != null) m["error"] = error.description
ctx.emit(m)
}
override fun bind(product: BaseProduct?) {
val op = waypointOp() ?: return
if (listenedOp !== op) {
listenedOp?.removeListener(waypointListener)
op.addListener(waypointListener)
listenedOp = op
}
emitWaypoint(state = op.currentState?.name)
}
override fun handle(call: MethodCall, result: MethodChannel.Result): Boolean {
when (call.method) {
"uploadWaypointMission" -> uploadWaypoint(call, result)
"getWaypointMissionState" -> {
val op = waypointOp()
result.success(op?.currentState?.name)
if (op != null) emitWaypoint(state = op.currentState?.name)
}
"startWaypointMission" -> waypoint(result) { it.startMission(ctx.completion(result)) }
"stopWaypointMission" -> waypoint(result) { it.stopMission(ctx.completion(result)) }
"pauseWaypointMission" -> waypoint(result) { it.pauseMission(ctx.completion(result)) }
"resumeWaypointMission" -> waypoint(result) { it.resumeMission(ctx.completion(result)) }
"cancelWaypointUpload" -> waypoint(result) { it.cancelUploadingMission(ctx.completion(result)) }
"setWaypointAutoFlightSpeed" -> waypoint(result) {
val speed = (call.argument<Number>("speed") ?: 8).toFloat()
it.setAutoFlightSpeed(speed, ctx.completion(result))
}
"startActiveTrack" -> startActiveTrack(call, result)
"stopActiveTrack" -> track(result) { it.stopTracking(ctx.completion(result)) }
"startQuickShot" -> startQuickShot(call, result)
@@ -55,41 +157,78 @@ class MissionBridge(private val ctx: BridgeCtx) : SubBridge {
block(op)
}
private inline fun <reified T : Enum<T>> enumArg(call: MethodCall, name: String, fallback: T): T {
val raw = call.argument<String>(name) ?: return fallback
return try {
java.lang.Enum.valueOf(T::class.java, raw)
} catch (_: IllegalArgumentException) {
fallback
}
}
private fun uploadWaypoint(call: MethodCall, result: MethodChannel.Result) {
val op = waypointOp() ?: run { ctx.fail(result, "Waypoint missions unavailable"); return }
val raw = call.argument<List<Map<String, Any>>>("points") ?: emptyList()
if (raw.size < 2) {
val raw = call.argument<List<Map<String, Any?>>>("waypoints") ?: emptyList()
if (raw.size < WaypointMission.MIN_WAYPOINT_COUNT) {
ctx.fail(result, "A route needs at least 2 waypoints")
return
}
val speed = (call.argument<Double>("speed") ?: 8.0).toFloat()
val finish = try {
WaypointMissionFinishedAction.valueOf(call.argument<String>("finishAction") ?: "NO_ACTION")
} catch (_: IllegalArgumentException) {
WaypointMissionFinishedAction.NO_ACTION
val waypoints = ArrayList<Waypoint>(raw.size)
for (p in raw) {
val wp = Waypoint(
(p["lat"] as Number).toDouble(),
(p["lon"] as Number).toDouble(),
(p["altitude"] as? Number)?.toFloat() ?: 50f,
)
(p["heading"] as? Number)?.let { wp.heading = it.toInt() }
wp.turnMode = if (p["turnMode"] == "COUNTER_CLOCKWISE") WaypointTurnMode.COUNTER_CLOCKWISE else WaypointTurnMode.CLOCKWISE
(p["speed"] as? Number)?.let { wp.speed = it.toFloat() }
(p["cornerRadius"] as? Number)?.let { wp.cornerRadiusInMeters = it.toFloat() }
(p["gimbalPitch"] as? Number)?.let { wp.gimbalPitch = it.toFloat() }
@Suppress("UNCHECKED_CAST")
val actions = p["actions"] as? List<Map<String, Any?>> ?: emptyList()
for (a in actions) {
val type = try {
WaypointActionType.valueOf(a["type"] as String)
} catch (_: Exception) {
continue
}
wp.addAction(WaypointAction(type, (a["param"] as? Number)?.toInt() ?: 0))
}
waypoints.add(wp)
}
val waypoints = raw.map { p ->
val lat = (p["lat"] as Number).toDouble()
val lon = (p["lon"] as Number).toDouble()
val alt = (p["altitude"] as? Number)?.toFloat() ?: 50f
Waypoint(lat, lon, alt)
}
val mission = WaypointMission.Builder()
.autoFlightSpeed(speed)
.maxFlightSpeed(maxOf(speed, 10f))
.finishedAction(finish)
.waypointList(ArrayList(waypoints))
val headingMode = enumArg(call, "headingMode", WaypointMissionHeadingMode.AUTO)
val builder = WaypointMission.Builder()
.autoFlightSpeed((call.argument<Number>("autoFlightSpeed") ?: 8).toFloat())
.maxFlightSpeed((call.argument<Number>("maxFlightSpeed") ?: 15).toFloat())
.finishedAction(enumArg(call, "finishedAction", WaypointMissionFinishedAction.NO_ACTION))
.headingMode(headingMode)
.flightPathMode(enumArg(call, "flightPathMode", WaypointMissionFlightPathMode.NORMAL))
.gotoFirstWaypointMode(enumArg(call, "gotoFirstWaypointMode", WaypointMissionGotoWaypointMode.SAFELY))
.setExitMissionOnRCSignalLostEnabled(call.argument<Boolean>("exitMissionOnRCSignalLost") ?: true)
.setGimbalPitchRotationEnabled(call.argument<Boolean>("gimbalPitchRotation") ?: false)
.repeatTimes(call.argument<Number>("repeatTimes")?.toInt() ?: 1)
.waypointList(waypoints)
.waypointCount(waypoints.size)
.build()
call.argument<Map<String, Any?>>("pointOfInterest")?.let { poi ->
builder.setPointOfInterest(
LocationCoordinate2D((poi["lat"] as Number).toDouble(), (poi["lon"] as Number).toDouble()),
)
}
val loadErr = op.loadMission(mission)
builder.checkParameters()?.let {
ctx.fail(result, it.description)
return
}
val loadErr = op.loadMission(builder.build())
if (loadErr != null) {
ctx.fail(result, loadErr.description)
return
}
ctx.emit(mapOf("type" to "mission", "state" to "uploading"))
// Completes once every waypoint is on the aircraft; progress arrives
// through the listener meanwhile.
op.uploadMission(ctx.completion(result))
}