Rebuild Fly App to design v2 with full DJI SDK integration

Rebuild the Flutter/DJI-MSDK-V4 Fly App to the v2 UI kit and wire the
full SDK surface behind it.

Native (Kotlin): split DjiSdkBridge into a method/event router delegating
to per-subsystem SubBridge helpers sharing a BridgeCtx — FlightController
(takeoff/land/RTH + rich telemetry), Camera (mode/record/photo/exposure),
Gimbal, Mission (Waypoint + ActiveTrack; QuickShots via ActiveTrack
QUICK_SHOT), Media (MediaManager list/thumbnail/download), and optional
DJI account login. Manifest gains scoped media permissions.

Flutter: ten screens under lib/ui/ (Flight HUD, capture modes, camera
settings, settings menu, map+waypoints, home, album, academy, profile,
routes/flight logs), driven by an expanded FlightModel. New PVIcon renders
the kit's SVG paths via flutter_svg; map uses flutter_map + latlong2.

Pin transitive androidx.core/browser down to SDK-35-compatible versions
so the newer plugins don't force AGP 8.9.1 onto the DJI toolchain.

Verified with `flutter build apk --debug` (compiles Dart + all Kotlin);
runtime behaviour is untested here — it needs a physical DJI-connected
device.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-07-15 16:51:08 +02:00
co-authored by Claude Opus 4.8
parent 3903428964
commit a4a2709456
29 changed files with 3766 additions and 696 deletions
+15
View File
@@ -124,6 +124,21 @@ dependencies {
implementation "androidx.recyclerview:recyclerview:1.3.2" implementation "androidx.recyclerview:recyclerview:1.3.2"
} }
// --- Keep transitive AndroidX on the AGP-8.6 / compileSdk-35 track -----------
// The newer Flutter plugins (url_launcher / path_provider / flutter_svg) pull
// androidx.core 1.17 and androidx.browser 1.9, whose aar metadata demands AGP
// 8.9.1 + compileSdk 36. The DJI MSDK V4 toolchain is pinned to AGP 8.6 / SDK 35
// (bumping it risks the fragile native build), so force these artifacts back to
// versions that build against SDK 35. Their APIs used by the plugins are stable
// across this range.
configurations.all {
resolutionStrategy {
force "androidx.core:core:1.13.1"
force "androidx.core:core-ktx:1.13.1"
force "androidx.browser:browser:1.8.0"
}
}
// --- Gradle 8 task-validation workaround ------------------------------------ // --- Gradle 8 task-validation workaround ------------------------------------
// Flutter's `compileFlutterBuild<Variant>` task declares an output directory // Flutter's `compileFlutterBuild<Variant>` task declares an output directory
// that overlaps the Android source sets, so Gradle 8's execution-time // that overlaps the Android source sets, so Gradle 8's execution-time
@@ -16,7 +16,11 @@
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" /> android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<!-- Scoped media access (API 33+) for downloaded drone photos/videos. -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<!-- Biometric / face authentication (local_auth). --> <!-- Biometric / face authentication (local_auth). -->
<uses-permission android:name="android.permission.USE_BIOMETRIC" /> <uses-permission android:name="android.permission.USE_BIOMETRIC" />
@@ -0,0 +1,62 @@
package com.dji.flutter.dji_msdk_sample
import dji.common.error.DJIError
import dji.common.useraccount.UserAccountState
import dji.common.util.CommonCallbacks
import dji.sdk.useraccount.UserAccountManager
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
/**
* Optional DJI user-account login (in addition to the PilotVault account).
* Unlocks NFZ-unlocking & flight-record sync where supported. Emits `djiAccount`
* state updates. Accessed via the static [UserAccountManager.getInstance].
*/
class AccountBridge(private val ctx: BridgeCtx) : SubBridge {
private val mgr get() = UserAccountManager.getInstance()
override fun handle(call: MethodCall, result: MethodChannel.Result): Boolean {
when (call.method) {
"djiLogin" -> {
mgr.logIntoDJIUserAccount(
ctx.appContext,
object : CommonCallbacks.CompletionCallbackWith<UserAccountState> {
override fun onSuccess(state: UserAccountState?) {
emitState(state)
ctx.mainHandler.post { result.success(state?.name) }
}
override fun onFailure(error: DJIError?) {
ctx.mainHandler.post {
result.error("DJI_ERROR", error?.description ?: "DJI login failed", null)
}
}
},
)
// Fetch the account name once logged in (best-effort).
mgr.getLoggedInDJIUserAccountName(object : CommonCallbacks.CompletionCallbackWith<String> {
override fun onSuccess(name: String?) {
ctx.emit(mapOf("type" to "djiAccount", "state" to mgr.userAccountState?.name, "user" to name))
}
override fun onFailure(error: DJIError?) {}
})
}
"djiLogout" -> {
mgr.logoutOfDJIUserAccount(ctx.completion(result))
ctx.emit(mapOf("type" to "djiAccount", "state" to "NOT_LOGGED_IN"))
}
"getDjiAccountState" -> {
emitState(mgr.userAccountState)
result.success(mgr.userAccountState?.name)
}
else -> return false
}
return true
}
private fun emitState(state: UserAccountState?) {
ctx.emit(mapOf("type" to "djiAccount", "state" to state?.name))
}
}
@@ -0,0 +1,171 @@
package com.dji.flutter.dji_msdk_sample
import dji.common.camera.ExposureSettings
import dji.common.camera.SettingsDefinitions
import dji.common.camera.SystemState
import dji.common.camera.WhiteBalance
import dji.sdk.base.BaseProduct
import dji.sdk.camera.Camera
import dji.sdk.products.Aircraft
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
/**
* Camera control: mode, photo/record, shoot-photo modes, and Pro exposure
* (ISO / shutter / aperture / EV / WB). Emits `camera` (system state) and
* `exposure` events. String↔enum mapping mirrors the v2 kit's scrubber labels;
* unsupported values fail cleanly rather than crashing.
*/
class CameraBridge(private val ctx: BridgeCtx) : SubBridge {
private fun cam(): Camera? = ctx.aircraft()?.camera
override fun handle(call: MethodCall, result: MethodChannel.Result): Boolean {
val camera = cam()
when (call.method) {
"setCameraMode" -> guard(camera, result) {
val mode = when (call.argument<String>("mode")) {
"photo" -> SettingsDefinitions.CameraMode.SHOOT_PHOTO
"video" -> SettingsDefinitions.CameraMode.RECORD_VIDEO
"mediaDownload" -> SettingsDefinitions.CameraMode.MEDIA_DOWNLOAD
"playback" -> SettingsDefinitions.CameraMode.PLAYBACK
else -> SettingsDefinitions.CameraMode.RECORD_VIDEO
}
it.setMode(mode, ctx.completion(result))
}
"startShootPhoto" -> guard(camera, result) { it.startShootPhoto(ctx.completion(result)) }
"stopShootPhoto" -> guard(camera, result) { it.stopShootPhoto(ctx.completion(result)) }
"startRecordVideo" -> guard(camera, result) { it.startRecordVideo(ctx.completion(result)) }
"stopRecordVideo" -> guard(camera, result) { it.stopRecordVideo(ctx.completion(result)) }
"setShootPhotoMode" -> guard(camera, result) {
val mode = enumOrNull<SettingsDefinitions.ShootPhotoMode>(call.argument<String>("mode"))
if (mode == null) ctx.fail(result, "Unsupported photo mode")
else it.setShootPhotoMode(mode, ctx.completion(result))
}
"setExposureProgram" -> guard(camera, result) {
val mode = if (call.argument<String>("program") == "pro")
SettingsDefinitions.ExposureMode.MANUAL
else SettingsDefinitions.ExposureMode.PROGRAM
it.setExposureMode(mode, ctx.completion(result))
}
"setISO" -> guard(camera, result) {
val v = enumOrNull<SettingsDefinitions.ISO>(isoName(call.argument<String>("value")))
if (v == null) ctx.fail(result, "Unsupported ISO") else it.setISO(v, ctx.completion(result))
}
"setShutterSpeed" -> guard(camera, result) {
val v = enumOrNull<SettingsDefinitions.ShutterSpeed>(shutterName(call.argument<String>("value")))
if (v == null) ctx.fail(result, "Unsupported shutter") else it.setShutterSpeed(v, ctx.completion(result))
}
"setAperture" -> guard(camera, result) {
val v = enumOrNull<SettingsDefinitions.Aperture>(apertureName(call.argument<String>("value")))
if (v == null) ctx.fail(result, "Unsupported aperture") else it.setAperture(v, ctx.completion(result))
}
"setEV" -> guard(camera, result) {
val v = enumOrNull<SettingsDefinitions.ExposureCompensation>(evName(call.argument<String>("value")))
if (v == null) ctx.fail(result, "Unsupported EV") else it.setExposureCompensation(v, ctx.completion(result))
}
"setWhiteBalance" -> guard(camera, result) {
val wb = whiteBalance(call.argument<String>("value"))
if (wb == null) ctx.fail(result, "Unsupported white balance")
else it.setWhiteBalance(wb, ctx.completion(result))
}
"setHistogramEnabled" -> {
// Histogram data streaming is model-dependent; accept the toggle so
// the UI stays responsive. Real histogram bins are a later add.
result.success(null)
}
else -> return false
}
return true
}
private inline fun guard(camera: Camera?, result: MethodChannel.Result, block: (Camera) -> Unit) {
if (camera == null) ctx.fail(result, "No camera connected") else block(camera)
}
override fun bind(product: BaseProduct?) {
if (product !is Aircraft) return
val camera = product.camera ?: return
camera.setSystemStateCallback { state: SystemState -> emitSystem(state) }
camera.setExposureSettingsCallback { settings: ExposureSettings -> emitExposure(settings) }
}
private fun emitSystem(state: SystemState) {
ctx.emit(
mapOf(
"type" to "camera",
"isRecording" to state.isRecording,
"recordingTimeSeconds" to state.currentVideoRecordingTimeInSeconds,
"mode" to state.mode?.name,
)
)
}
private fun emitExposure(s: ExposureSettings) {
ctx.emit(
mapOf(
"type" to "exposure",
"iso" to s.iso.toString(),
"shutter" to shutterLabel(s.shutterSpeed?.name),
"aperture" to apertureLabel(s.aperture?.name),
"ev" to evLabel(s.exposureCompensation?.name),
)
)
}
// ── string → enum-name mapping (kit scrubber labels) ────────────────────────
private fun isoName(v: String?): String? = when {
v == null -> null
v.equals("AUTO", true) -> "ISO_AUTO"
else -> "ISO_$v"
}
private fun shutterName(v: String?): String? =
if (v == null) null else "SHUTTER_SPEED_" + v.replace(".", "_DOT_").replace("/", "_")
private fun apertureName(v: String?): String? =
if (v == null) null else "F_" + v.replace("f/", "", true).replace(".", "_DOT_")
private fun evName(v: String?): String? {
if (v == null) return null
val clean = v.replace("+", "").trim()
if (clean == "0" || clean == "0.0") return "N_0_0"
val neg = clean.startsWith("-")
val digits = clean.removePrefix("-").replace(".", "_")
return (if (neg) "N_" else "P_") + digits
}
private fun whiteBalance(v: String?): WhiteBalance? {
if (v == null) return null
if (v.equals("AUTO", true)) {
return WhiteBalance(SettingsDefinitions.WhiteBalancePreset.AUTO)
}
val kelvin = v.replace("K", "", true).trim().toIntOrNull() ?: return null
// DJI custom colour temperature is expressed in units of 100 K.
return WhiteBalance(SettingsDefinitions.WhiteBalancePreset.CUSTOM, kelvin / 100)
}
// ── enum-name → label mapping (for the read-back model) ─────────────────────
private fun shutterLabel(name: String?): String? =
name?.removePrefix("SHUTTER_SPEED_")?.replace("_DOT_", ".")?.replace("_", "/")
private fun apertureLabel(name: String?): String? =
name?.removePrefix("F_")?.replace("_DOT_", ".")?.let { "f/$it" }
private fun evLabel(name: String?): String? {
if (name == null) return null
val neg = name.startsWith("N_")
val body = name.removePrefix("N_").removePrefix("P_").replace("_", ".")
if (body == "0.0") return "0.0"
return (if (neg) "-" else "+") + body
}
private inline fun <reified T : Enum<T>> enumOrNull(name: String?): T? {
if (name == null) return null
return try {
enumValueOf<T>(name)
} catch (_: IllegalArgumentException) {
null
}
}
}
@@ -6,7 +6,7 @@ import android.os.Looper
import dji.common.battery.BatteryState import dji.common.battery.BatteryState
import dji.common.error.DJIError import dji.common.error.DJIError
import dji.common.error.DJISDKError import dji.common.error.DJISDKError
import dji.common.flightcontroller.FlightControllerState import dji.common.util.CommonCallbacks
import dji.sdk.base.BaseComponent import dji.sdk.base.BaseComponent
import dji.sdk.base.BaseProduct import dji.sdk.base.BaseProduct
import dji.sdk.products.Aircraft import dji.sdk.products.Aircraft
@@ -20,8 +20,11 @@ import io.flutter.plugin.common.MethodChannel
/** /**
* Bridges the DJI Mobile SDK V4 to Flutter. * Bridges the DJI Mobile SDK V4 to Flutter.
* *
* - [METHOD_CHANNEL] handles imperative calls from Dart (register, connect, query). * - [METHOD_CHANNEL] handles imperative calls from Dart, routed to per-subsystem
* - [EVENT_CHANNEL] streams registration / connection / telemetry updates to Dart. * helpers ([FlightControllerBridge], [CameraBridge], [GimbalBridge],
* [MissionBridge], [MediaBridge], [AccountBridge]).
* - [EVENT_CHANNEL] streams every subsystem's async updates to Dart. Each event
* is a map carrying a `type` key.
* *
* All SDK callbacks arrive on arbitrary threads, so every event is marshalled to * All SDK callbacks arrive on arbitrary threads, so every event is marshalled to
* the main thread before being pushed into the Flutter [EventChannel.EventSink]. * the main thread before being pushed into the Flutter [EventChannel.EventSink].
@@ -42,6 +45,23 @@ class DjiSdkBridge(
private var eventSink: EventChannel.EventSink? = null private var eventSink: EventChannel.EventSink? = null
/** Shared context handed to every subsystem helper. */
private val ctx = BridgeCtx(
appContext = appContext,
mainHandler = mainHandler,
emit = ::emit,
product = { DJISDKManager.getInstance().product },
)
private val flight = FlightControllerBridge(ctx)
private val camera = CameraBridge(ctx)
private val gimbal = GimbalBridge(ctx)
private val mission = MissionBridge(ctx)
private val media = MediaBridge(ctx)
private val account = AccountBridge(ctx)
private val subBridges = listOf(flight, camera, gimbal, mission, media, account)
init { init {
methodChannel.setMethodCallHandler(this) methodChannel.setMethodCallHandler(this)
eventChannel.setStreamHandler(this) eventChannel.setStreamHandler(this)
@@ -50,28 +70,35 @@ class DjiSdkBridge(
// ── MethodChannel ────────────────────────────────────────────────────────── // ── MethodChannel ──────────────────────────────────────────────────────────
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
// Core (SDK lifecycle) methods first, then delegate to subsystem helpers.
when (call.method) { when (call.method) {
"getSdkVersion" -> "getSdkVersion" -> {
result.success(DJISDKManager.getInstance().sdkVersion) result.success(DJISDKManager.getInstance().sdkVersion)
return
}
"registerApp" -> { "registerApp" -> {
registerApp() registerApp()
result.success(null) result.success(null)
return
} }
"startConnection" -> {
"startConnection" ->
result.success(DJISDKManager.getInstance().startConnectionToProduct()) result.success(DJISDKManager.getInstance().startConnectionToProduct())
return
}
"stopConnection" -> { "stopConnection" -> {
DJISDKManager.getInstance().stopConnectionToProduct() DJISDKManager.getInstance().stopConnectionToProduct()
result.success(null) result.success(null)
return
} }
"getProductInfo" -> {
"getProductInfo" ->
result.success(connectionMap(DJISDKManager.getInstance().product)) result.success(connectionMap(DJISDKManager.getInstance().product))
return
else -> result.notImplemented() }
} }
for (sub in subBridges) {
if (sub.handle(call, result)) return
}
result.notImplemented()
} }
// ── EventChannel ─────────────────────────────────────────────────────────── // ── EventChannel ───────────────────────────────────────────────────────────
@@ -100,7 +127,6 @@ class DjiSdkBridge(
override fun onRegister(error: DJIError?) { override fun onRegister(error: DJIError?) {
if (error == DJISDKError.REGISTRATION_SUCCESS) { if (error == DJISDKError.REGISTRATION_SUCCESS) {
emit(mapOf("type" to "registration", "state" to "success")) emit(mapOf("type" to "registration", "state" to "success"))
// Begin scanning for an attached product (USB RC / Wi-Fi).
DJISDKManager.getInstance().startConnectionToProduct() DJISDKManager.getInstance().startConnectionToProduct()
} else { } else {
emit( emit(
@@ -132,8 +158,6 @@ class DjiSdkBridge(
oldComponent: BaseComponent?, oldComponent: BaseComponent?,
newComponent: BaseComponent?, newComponent: BaseComponent?,
) { ) {
// A component (e.g. flight controller, battery) appeared/changed —
// (re)attach the telemetry callbacks.
bindComponentCallbacks(DJISDKManager.getInstance().product) bindComponentCallbacks(DJISDKManager.getInstance().product)
} }
@@ -151,30 +175,20 @@ class DjiSdkBridge(
private fun connectionMap(product: BaseProduct?): Map<String, Any?> { private fun connectionMap(product: BaseProduct?): Map<String, Any?> {
val connected = product != null && product.isConnected val connected = product != null && product.isConnected
val model = product?.model?.displayName val model = product?.model?.displayName
return mapOf("type" to "connection", "connected" to connected, "model" to model) val firmware = product?.firmwarePackageVersion
return mapOf(
"type" to "connection",
"connected" to connected,
"model" to model,
"firmware" to firmware,
)
} }
/** Attaches flight-controller and battery state listeners when on an aircraft. */ /** (Re)attaches every subsystem's state listeners when a component appears. */
private fun bindComponentCallbacks(product: BaseProduct?) { private fun bindComponentCallbacks(product: BaseProduct?) {
if (product !is Aircraft) return for (sub in subBridges) sub.bind(product)
product.flightController?.setStateCallback { state: FlightControllerState -> if (product !is Aircraft) return
val location = state.aircraftLocation
emit(
mapOf(
"type" to "telemetry",
"satelliteCount" to state.satelliteCount,
"isFlying" to state.isFlying,
"flightMode" to state.flightModeString,
"altitude" to location?.altitude,
"latitude" to location?.latitude,
"longitude" to location?.longitude,
"velocityX" to state.velocityX,
"velocityY" to state.velocityY,
"velocityZ" to state.velocityZ,
)
)
}
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
product.battery?.setStateCallback { batteryState: BatteryState -> product.battery?.setStateCallback { batteryState: BatteryState ->
@@ -182,8 +196,49 @@ class DjiSdkBridge(
mapOf( mapOf(
"type" to "battery", "type" to "battery",
"percent" to batteryState.chargeRemainingInPercent, "percent" to batteryState.chargeRemainingInPercent,
"voltage" to batteryState.voltage / 1000.0,
"temperature" to batteryState.temperature,
) )
) )
} }
} }
} }
/**
* Shared services handed to each subsystem helper: the emit sink, main-thread
* handler, app context, and a live accessor for the connected product. Also
* provides the boilerplate completion callback that answers a Flutter [result].
*/
class BridgeCtx(
val appContext: Context,
val mainHandler: Handler,
val emit: (Map<String, Any?>) -> Unit,
val product: () -> BaseProduct?,
) {
fun aircraft(): Aircraft? = product() as? Aircraft
/** A one-shot SDK completion callback that resolves the Flutter [result]. */
fun completion(result: MethodChannel.Result): CommonCallbacks.CompletionCallback<DJIError> {
return object : CommonCallbacks.CompletionCallback<DJIError> {
override fun onResult(error: DJIError?) {
mainHandler.post {
if (error == null) result.success(null)
else result.error("DJI_ERROR", error.description, null)
}
}
}
}
fun fail(result: MethodChannel.Result, message: String) {
mainHandler.post { result.error("DJI_UNAVAILABLE", message, null) }
}
}
/** A subsystem method/event helper delegated to by [DjiSdkBridge]. */
interface SubBridge {
/** Returns true if it owns [call] (and has answered [result]). */
fun handle(call: MethodCall, result: MethodChannel.Result): Boolean
/** (Re)attach any component state listeners for the connected product. */
fun bind(product: BaseProduct?) {}
}
@@ -0,0 +1,130 @@
package com.dji.flutter.dji_msdk_sample
import dji.common.flightcontroller.FlightControllerState
import dji.sdk.base.BaseProduct
import dji.sdk.flightcontroller.FlightController
import dji.sdk.products.Aircraft
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.hypot
import kotlin.math.sin
import kotlin.math.sqrt
/**
* Flight-controller commands (takeoff / land / RTH), safety & control settings,
* and the enriched `telemetry` stream. Delegated to by [DjiSdkBridge].
*/
class FlightControllerBridge(private val ctx: BridgeCtx) : SubBridge {
private fun fc(): FlightController? = ctx.aircraft()?.flightController
override fun handle(call: MethodCall, result: MethodChannel.Result): Boolean {
val controller = fc()
when (call.method) {
"takeOff" -> guard(controller, result) { it.startTakeoff(ctx.completion(result)) }
"land" -> guard(controller, result) { it.startLanding(ctx.completion(result)) }
"confirmLanding" -> guard(controller, result) { it.confirmLanding(ctx.completion(result)) }
"cancelLanding" -> guard(controller, result) { it.cancelLanding(ctx.completion(result)) }
"startGoHome" -> guard(controller, result) { it.startGoHome(ctx.completion(result)) }
"cancelGoHome" -> guard(controller, result) { it.cancelGoHome(ctx.completion(result)) }
"setHomeToCurrent" -> guard(controller, result) {
it.setHomeLocationUsingAircraftCurrentLocation(ctx.completion(result))
}
"setMaxFlightHeight" -> guard(controller, result) {
it.setMaxFlightHeight((call.argument<Int>("value") ?: 120), ctx.completion(result))
}
"setMaxFlightRadius" -> guard(controller, result) {
it.setMaxFlightRadius((call.argument<Int>("value") ?: 500), ctx.completion(result))
}
"setMaxRadiusEnabled" -> guard(controller, result) {
it.setMaxFlightRadiusLimitationEnabled(
call.argument<Boolean>("value") ?: false, ctx.completion(result)
)
}
"setGoHomeHeight" -> guard(controller, result) {
it.setGoHomeHeightInMeters((call.argument<Int>("value") ?: 100), ctx.completion(result))
}
"setNoviceMode" -> guard(controller, result) {
it.setNoviceModeEnabled(call.argument<Boolean>("value") ?: false, ctx.completion(result))
}
"setObstacleAvoidance" -> {
val assistant = controller?.flightAssistant
if (assistant == null) {
ctx.fail(result, "Obstacle avoidance unavailable on this aircraft")
} else {
assistant.setCollisionAvoidanceEnabled(
call.argument<Boolean>("value") ?: true, ctx.completion(result)
)
}
}
else -> return false
}
return true
}
private inline fun guard(
controller: FlightController?,
result: MethodChannel.Result,
block: (FlightController) -> Unit,
) {
if (controller == null) ctx.fail(result, "No aircraft connected") else block(controller)
}
override fun bind(product: BaseProduct?) {
if (product !is Aircraft) return
product.flightController?.setStateCallback { state -> emitTelemetry(state) }
}
private fun emitTelemetry(state: FlightControllerState) {
val loc = state.aircraftLocation
val home = state.homeLocation
val vx = state.velocityX
val vy = state.velocityY
val vz = state.velocityZ
val horizontalSpeed = hypot(vx.toDouble(), vy.toDouble())
var homeDistance: Double? = null
if (loc != null && home != null) {
homeDistance = haversine(
loc.latitude, loc.longitude, home.latitude, home.longitude
)
}
ctx.emit(
mapOf(
"type" to "telemetry",
"satelliteCount" to state.satelliteCount,
"gpsSignalLevel" to (state.gpsSignalLevel?.value()),
"isFlying" to state.isFlying,
"areMotorsOn" to state.areMotorsOn(),
"flightMode" to state.flightModeString,
"altitude" to loc?.altitude,
"latitude" to loc?.latitude,
"longitude" to loc?.longitude,
"homeLatitude" to home?.latitude,
"homeLongitude" to home?.longitude,
"homeDistance" to homeDistance,
"horizontalSpeed" to horizontalSpeed,
"verticalSpeed" to -vz.toDouble(),
"heading" to state.attitude?.yaw,
"goHomeHeight" to state.goHomeHeight,
"velocityX" to vx,
"velocityY" to vy,
"velocityZ" to vz,
)
)
}
/** Great-circle distance in metres. */
private fun haversine(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
val r = 6371000.0
val dLat = Math.toRadians(lat2 - lat1)
val dLon = Math.toRadians(lon2 - lon1)
val a = sin(dLat / 2) * sin(dLat / 2) +
cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) *
sin(dLon / 2) * sin(dLon / 2)
return r * 2 * atan2(sqrt(a), sqrt(1 - a))
}
}
@@ -0,0 +1,55 @@
package com.dji.flutter.dji_msdk_sample
import dji.common.gimbal.Rotation
import dji.common.gimbal.RotationMode
import dji.sdk.base.BaseProduct
import dji.sdk.gimbal.Gimbal
import dji.sdk.products.Aircraft
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
* telemetry (`gimbal` event).
*/
class GimbalBridge(private val ctx: BridgeCtx) : SubBridge {
private fun gimbal(): Gimbal? = ctx.aircraft()?.gimbal
override fun handle(call: MethodCall, result: MethodChannel.Result): Boolean {
val g = gimbal()
when (call.method) {
"rotateGimbalPitch" -> guard(g, result) {
val pitch = (call.argument<Double>("pitch") ?: 0.0).toFloat()
val rotation = Rotation.Builder()
.mode(RotationMode.ABSOLUTE_ANGLE)
.pitch(pitch)
.time(0.5)
.build()
it.rotate(rotation, ctx.completion(result))
}
"resetGimbal" -> guard(g, result) { it.reset(ctx.completion(result)) }
else -> return false
}
return true
}
private inline fun guard(g: Gimbal?, result: MethodChannel.Result, block: (Gimbal) -> Unit) {
if (g == null) ctx.fail(result, "No gimbal connected") else block(g)
}
override fun bind(product: BaseProduct?) {
if (product !is Aircraft) return
product.gimbal?.setStateCallback { state ->
val a = state.attitudeInDegrees
ctx.emit(
mapOf(
"type" to "gimbal",
"pitch" to a?.pitch,
"roll" to a?.roll,
"yaw" to a?.yaw,
)
)
}
}
}
@@ -0,0 +1,146 @@
package com.dji.flutter.dji_msdk_sample
import android.graphics.Bitmap
import dji.common.camera.SettingsDefinitions
import dji.common.error.DJIError
import dji.common.util.CommonCallbacks
import dji.sdk.media.DownloadListener
import dji.sdk.media.MediaFile
import dji.sdk.media.MediaManager
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.io.File
import java.io.FileOutputStream
/**
* On-drone media via the SDK [MediaManager]: list the SD card, fetch thumbnails,
* and download originals. Emits `mediaList` (the file catalogue) and
* `mediaDownload` (per-file progress). Thumbnails/downloads are written to the
* app's cache / files dirs and their paths handed back to Dart.
*/
class MediaBridge(private val ctx: BridgeCtx) : SubBridge {
// Snapshot of the last-refreshed file list; Dart addresses files by index.
private var files: List<MediaFile> = emptyList()
private fun manager(): MediaManager? = ctx.aircraft()?.camera?.mediaManager
override fun handle(call: MethodCall, result: MethodChannel.Result): Boolean {
when (call.method) {
"refreshMediaList" -> refresh(result)
"fetchThumbnail" -> fetchThumbnail(call.argument<Int>("index") ?: -1, result)
"downloadMedia" -> download(call.argument<Int>("index") ?: -1, result)
else -> return false
}
return true
}
private fun refresh(result: MethodChannel.Result) {
val camera = ctx.aircraft()?.camera ?: run { ctx.fail(result, "No camera connected"); return }
val mgr = camera.mediaManager ?: run { ctx.fail(result, "Media manager not supported"); return }
// The media manager only serves files while the camera is in download mode.
camera.setMode(
SettingsDefinitions.CameraMode.MEDIA_DOWNLOAD,
object : CommonCallbacks.CompletionCallback<DJIError> {
override fun onResult(modeErr: DJIError?) {
if (modeErr != null) {
ctx.mainHandler.post { result.error("DJI_ERROR", modeErr.description, null) }
return
}
mgr.refreshFileListOfStorageLocation(
SettingsDefinitions.StorageLocation.SDCARD,
object : CommonCallbacks.CompletionCallback<DJIError> {
override fun onResult(err: DJIError?) {
if (err != null) {
ctx.mainHandler.post { result.error("DJI_ERROR", err.description, null) }
return
}
files = mgr.sdCardFileListSnapshot ?: emptyList()
emitList()
ctx.mainHandler.post { result.success(files.size) }
}
},
)
}
},
)
}
private fun emitList() {
val list = files.mapIndexed { i, f ->
val type = f.mediaType
val isVideo = type == MediaFile.MediaType.MP4 || type == MediaFile.MediaType.MOV
mapOf(
"index" to i,
"fileName" to f.fileName,
"type" to if (isVideo) "video" else "photo",
"durationSeconds" to f.durationInSeconds.toInt(),
"sizeBytes" to f.fileSize,
"createdMs" to f.timeCreated,
)
}
ctx.emit(mapOf("type" to "mediaList", "files" to list))
}
private fun fetchThumbnail(index: Int, result: MethodChannel.Result) {
val file = files.getOrNull(index) ?: run { ctx.fail(result, "No such media"); return }
file.fetchThumbnail(object : CommonCallbacks.CompletionCallback<DJIError> {
override fun onResult(err: DJIError?) {
if (err != null) {
ctx.mainHandler.post { result.error("DJI_ERROR", err.description, null) }
return
}
val bmp: Bitmap? = file.thumbnail
if (bmp == null) {
ctx.mainHandler.post { result.success(null) }
return
}
val out = File(ctx.appContext.cacheDir, "thumb_$index.png")
try {
FileOutputStream(out).use { bmp.compress(Bitmap.CompressFormat.PNG, 90, it) }
ctx.mainHandler.post { result.success(out.absolutePath) }
} catch (e: Exception) {
ctx.mainHandler.post { result.error("IO_ERROR", e.message, null) }
}
}
})
}
private fun download(index: Int, result: MethodChannel.Result) {
val file = files.getOrNull(index) ?: run { ctx.fail(result, "No such media"); return }
val destDir = ctx.appContext.getExternalFilesDir(null) ?: ctx.appContext.filesDir
var answered = false
file.fetchFileData(destDir, file.fileName, object : DownloadListener<String> {
override fun onStart() {
ctx.emit(mapOf("type" to "mediaDownload", "index" to index, "state" to "downloading", "progress" to 0.0))
}
override fun onRateUpdate(total: Long, current: Long, persize: Long) {}
override fun onRealtimeDataUpdate(data: ByteArray?, position: Long, isBegin: Boolean) {}
override fun onProgress(total: Long, current: Long) {
val p = if (total > 0) current.toDouble() / total.toDouble() else 0.0
ctx.emit(mapOf("type" to "mediaDownload", "index" to index, "state" to "downloading", "progress" to p))
}
override fun onSuccess(path: String?) {
val full = if (path != null) File(destDir, file.fileName).absolutePath else null
ctx.emit(mapOf("type" to "mediaDownload", "index" to index, "state" to "done", "progress" to 1.0, "path" to full))
if (!answered) {
answered = true
ctx.mainHandler.post { result.success(full) }
}
}
override fun onFailure(error: DJIError?) {
ctx.emit(mapOf("type" to "mediaDownload", "index" to index, "state" to "failed"))
if (!answered) {
answered = true
ctx.mainHandler.post { result.error("DJI_ERROR", error?.description ?: "Download failed", null) }
}
}
})
}
}
@@ -0,0 +1,126 @@
package com.dji.flutter.dji_msdk_sample
import android.graphics.RectF
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.WaypointMission
import dji.common.mission.waypoint.WaypointMissionFinishedAction
import dji.sdk.mission.activetrack.ActiveTrackOperator
import dji.sdk.mission.waypoint.WaypointMissionOperator
import dji.sdk.sdkmanager.DJISDKManager
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
/**
* Intelligent-flight & waypoint missions.
*
* - Waypoint route (from the Map screen) → [WaypointMissionOperator].
* - ActiveTrack subject tracking → [ActiveTrackOperator].
* - DJI-Fly QuickShots (Dronie/Rocket/Circle/Helix/Boomerang/Asteroid) → an
* ActiveTrack mission in [ActiveTrackMode.QUICK_SHOT] with the matching
* [QuickShotMode]. Unsupported modes on a given aircraft fail cleanly.
*/
class MissionBridge(private val ctx: BridgeCtx) : SubBridge {
private fun waypointOp(): WaypointMissionOperator? =
DJISDKManager.getInstance().missionControl?.waypointMissionOperator
private fun trackOp(): ActiveTrackOperator? =
DJISDKManager.getInstance().missionControl?.activeTrackOperator
override fun handle(call: MethodCall, result: MethodChannel.Result): Boolean {
when (call.method) {
"uploadWaypointMission" -> uploadWaypoint(call, result)
"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)) }
"startActiveTrack" -> startActiveTrack(call, result)
"stopActiveTrack" -> track(result) { it.stopTracking(ctx.completion(result)) }
"startQuickShot" -> startQuickShot(call, result)
else -> return false
}
return true
}
private inline fun waypoint(result: MethodChannel.Result, block: (WaypointMissionOperator) -> Unit) {
val op = waypointOp() ?: run { ctx.fail(result, "Waypoint missions unavailable"); return }
block(op)
}
private inline fun track(result: MethodChannel.Result, block: (ActiveTrackOperator) -> Unit) {
val op = trackOp() ?: run { ctx.fail(result, "ActiveTrack unavailable"); return }
block(op)
}
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) {
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 = 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))
.waypointCount(waypoints.size)
.build()
val loadErr = op.loadMission(mission)
if (loadErr != null) {
ctx.fail(result, loadErr.description)
return
}
ctx.emit(mapOf("type" to "mission", "state" to "uploading"))
op.uploadMission(ctx.completion(result))
}
private fun startActiveTrack(call: MethodCall, result: MethodChannel.Result) {
val op = trackOp() ?: run { ctx.fail(result, "ActiveTrack unavailable"); return }
val x = (call.argument<Double>("x") ?: 0.4).toFloat()
val y = (call.argument<Double>("y") ?: 0.35).toFloat()
val w = (call.argument<Double>("w") ?: 0.2).toFloat()
val h = (call.argument<Double>("h") ?: 0.3).toFloat()
val mode = try {
ActiveTrackMode.valueOf(call.argument<String>("mode") ?: "TRACE")
} catch (_: IllegalArgumentException) {
ActiveTrackMode.TRACE
}
val mission = ActiveTrackMission(RectF(x, y, x + w, y + h), mode)
ctx.emit(mapOf("type" to "mission", "state" to "tracking"))
op.startTracking(mission, ctx.completion(result))
}
private fun startQuickShot(call: MethodCall, result: MethodChannel.Result) {
val op = trackOp() ?: run { ctx.fail(result, "QuickShots unavailable"); return }
val name = call.argument<String>("name")?.uppercase() ?: ""
val quick = try {
QuickShotMode.valueOf(name)
} catch (_: IllegalArgumentException) {
ctx.fail(result, "$name is not supported on this aircraft")
return
}
val mission = ActiveTrackMission(RectF(0.4f, 0.35f, 0.6f, 0.65f), ActiveTrackMode.QUICK_SHOT)
mission.quickShotMode = quick
ctx.emit(mapOf("type" to "mission", "state" to "quickshot"))
op.startTracking(mission, ctx.completion(result))
}
}
+125 -13
View File
@@ -2,38 +2,150 @@ import 'package:flutter/services.dart';
/// Thin Dart wrapper over the native DJI Mobile SDK bridge. /// Thin Dart wrapper over the native DJI Mobile SDK bridge.
/// ///
/// Mirrors the channels defined in `DjiSdkBridge.kt`: /// Mirrors the channels defined in `DjiSdkBridge.kt` (+ its per-subsystem
/// helpers):
/// * method channel `dji_msdk/methods` for imperative calls /// * method channel `dji_msdk/methods` for imperative calls
/// * event channel `dji_msdk/events` for the SDK's async updates /// * event channel `dji_msdk/events` for the SDK's async updates
///
/// Every method returns cleanly whether or not a product is connected; the
/// native side answers with a typed error the UI can surface. The event stream
/// carries maps keyed by `type`: `registration`, `connection`, `telemetry`,
/// `battery`, `camera`, `exposure`, `gimbal`, `mission`, `mediaList`,
/// `mediaDownload`, `djiAccount`, `database`, `init`.
class DjiService { class DjiService {
static const MethodChannel _methods = MethodChannel('dji_msdk/methods'); static const MethodChannel _methods = MethodChannel('dji_msdk/methods');
static const EventChannel _events = EventChannel('dji_msdk/events'); static const EventChannel _events = EventChannel('dji_msdk/events');
/// Broadcast stream of SDK events. Each event is a map with a `type` key:
/// `registration`, `connection`, `telemetry`, `battery`, `database`, `init`.
Stream<Map<String, dynamic>> events() { Stream<Map<String, dynamic>> events() {
return _events return _events
.receiveBroadcastStream() .receiveBroadcastStream()
.map((dynamic e) => Map<String, dynamic>.from(e as Map)); .map((dynamic e) => Map<String, dynamic>.from(e as Map));
} }
Future<String> getSdkVersion() async { // ── SDK / registration / connection ─────────────────────────────────────────
return await _methods.invokeMethod<String>('getSdkVersion') ?? 'unknown'; Future<String> getSdkVersion() async =>
} await _methods.invokeMethod<String>('getSdkVersion') ?? 'unknown';
/// Kicks off DJI app registration (requires a valid App Key + internet).
Future<void> registerApp() => _methods.invokeMethod<void>('registerApp'); Future<void> registerApp() => _methods.invokeMethod<void>('registerApp');
/// Starts scanning for a connected product (USB remote controller / Wi-Fi). Future<bool> startConnection() async =>
Future<bool> startConnection() async { await _methods.invokeMethod<bool>('startConnection') ?? false;
return await _methods.invokeMethod<bool>('startConnection') ?? false;
}
Future<void> stopConnection() => Future<void> stopConnection() => _methods.invokeMethod<void>('stopConnection');
_methods.invokeMethod<void>('stopConnection');
Future<Map<String, dynamic>> getProductInfo() async { Future<Map<String, dynamic>> getProductInfo() async {
final dynamic info = await _methods.invokeMethod('getProductInfo'); final dynamic info = await _methods.invokeMethod('getProductInfo');
return Map<String, dynamic>.from(info as Map); return Map<String, dynamic>.from(info as Map);
} }
// ── Flight controller commands ──────────────────────────────────────────────
Future<void> takeOff() => _methods.invokeMethod<void>('takeOff');
Future<void> land() => _methods.invokeMethod<void>('land');
Future<void> confirmLanding() => _methods.invokeMethod<void>('confirmLanding');
Future<void> cancelLanding() => _methods.invokeMethod<void>('cancelLanding');
Future<void> startGoHome() => _methods.invokeMethod<void>('startGoHome');
Future<void> cancelGoHome() => _methods.invokeMethod<void>('cancelGoHome');
Future<void> setHomeToCurrent() => _methods.invokeMethod<void>('setHomeToCurrent');
// ── Flight settings (Safety / Control) ──────────────────────────────────────
Future<void> setMaxFlightHeight(int m) =>
_methods.invokeMethod<void>('setMaxFlightHeight', <String, dynamic>{'value': m});
Future<void> setMaxFlightRadius(int m) =>
_methods.invokeMethod<void>('setMaxFlightRadius', <String, dynamic>{'value': m});
Future<void> setMaxRadiusEnabled(bool on) =>
_methods.invokeMethod<void>('setMaxRadiusEnabled', <String, dynamic>{'value': on});
Future<void> setGoHomeHeight(int m) =>
_methods.invokeMethod<void>('setGoHomeHeight', <String, dynamic>{'value': m});
Future<void> setNoviceMode(bool on) =>
_methods.invokeMethod<void>('setNoviceMode', <String, dynamic>{'value': on});
Future<void> setObstacleAvoidance(bool on) =>
_methods.invokeMethod<void>('setObstacleAvoidance', <String, dynamic>{'value': on});
// ── Camera ──────────────────────────────────────────────────────────────────
/// mode: `photo` | `video` | `mediaDownload` | `playback`
Future<void> setCameraMode(String mode) =>
_methods.invokeMethod<void>('setCameraMode', <String, dynamic>{'mode': mode});
Future<void> startShootPhoto() => _methods.invokeMethod<void>('startShootPhoto');
Future<void> stopShootPhoto() => _methods.invokeMethod<void>('stopShootPhoto');
Future<void> startRecordVideo() => _methods.invokeMethod<void>('startRecordVideo');
Future<void> stopRecordVideo() => _methods.invokeMethod<void>('stopRecordVideo');
/// mode: SINGLE | HDR | BURST | AEB | INTERVAL | PANORAMA
Future<void> setShootPhotoMode(String mode) =>
_methods.invokeMethod<void>('setShootPhotoMode', <String, dynamic>{'mode': mode});
/// program: `auto` (PROGRAM) | `pro` (MANUAL)
Future<void> setExposureProgram(String program) =>
_methods.invokeMethod<void>('setExposureProgram', <String, dynamic>{'program': program});
Future<void> setISO(String value) =>
_methods.invokeMethod<void>('setISO', <String, dynamic>{'value': value});
Future<void> setShutterSpeed(String value) =>
_methods.invokeMethod<void>('setShutterSpeed', <String, dynamic>{'value': value});
Future<void> setAperture(String value) =>
_methods.invokeMethod<void>('setAperture', <String, dynamic>{'value': value});
Future<void> setEV(String value) =>
_methods.invokeMethod<void>('setEV', <String, dynamic>{'value': value});
Future<void> setWhiteBalance(String value) =>
_methods.invokeMethod<void>('setWhiteBalance', <String, dynamic>{'value': value});
Future<void> setHistogramEnabled(bool on) =>
_methods.invokeMethod<void>('setHistogramEnabled', <String, dynamic>{'value': on});
// ── Gimbal ──────────────────────────────────────────────────────────────────
Future<void> rotateGimbalPitch(double deg) =>
_methods.invokeMethod<void>('rotateGimbalPitch', <String, dynamic>{'pitch': deg});
Future<void> resetGimbal() => _methods.invokeMethod<void>('resetGimbal');
// ── Missions ────────────────────────────────────────────────────────────────
/// points: [{lat, lon, altitude}], finishAction: NO_ACTION|GO_HOME|AUTO_LAND|GO_FIRST_WAYPOINT
Future<void> uploadWaypointMission(
List<Map<String, dynamic>> points, {
double speed = 8,
String finishAction = 'NO_ACTION',
}) =>
_methods.invokeMethod<void>('uploadWaypointMission', <String, dynamic>{
'points': points,
'speed': speed,
'finishAction': finishAction,
});
Future<void> startWaypointMission() => _methods.invokeMethod<void>('startWaypointMission');
Future<void> stopWaypointMission() => _methods.invokeMethod<void>('stopWaypointMission');
Future<void> pauseWaypointMission() => _methods.invokeMethod<void>('pauseWaypointMission');
Future<void> resumeWaypointMission() => _methods.invokeMethod<void>('resumeWaypointMission');
/// Rect is normalized 0..1 in the live view. mode: TRACE|PROFILE|SPOTLIGHT|QUICK_SHOT
Future<void> startActiveTrack(
double x,
double y,
double w,
double h, {
String mode = 'TRACE',
}) =>
_methods.invokeMethod<void>('startActiveTrack', <String, dynamic>{
'x': x,
'y': y,
'w': w,
'h': h,
'mode': mode,
});
Future<void> stopActiveTrack() => _methods.invokeMethod<void>('stopActiveTrack');
/// A DJI-Fly QuickShot by name (Dronie/Rocket/Circle/Helix/Boomerang/Asteroid).
/// The native side maps each to the closest real operator or returns a typed
/// "unsupported on this aircraft" error.
Future<void> startQuickShot(String name) =>
_methods.invokeMethod<void>('startQuickShot', <String, dynamic>{'name': name});
// ── Media (MediaManager) ────────────────────────────────────────────────────
Future<void> refreshMediaList() => _methods.invokeMethod<void>('refreshMediaList');
Future<String?> fetchThumbnail(int index) =>
_methods.invokeMethod<String>('fetchThumbnail', <String, dynamic>{'index': index});
Future<String?> downloadMedia(int index) =>
_methods.invokeMethod<String>('downloadMedia', <String, dynamic>{'index': index});
Future<void> deleteMedia(int index) =>
_methods.invokeMethod<void>('deleteMedia', <String, dynamic>{'index': index});
// ── DJI account (optional) ──────────────────────────────────────────────────
Future<void> djiLogin() => _methods.invokeMethod<void>('djiLogin');
Future<void> djiLogout() => _methods.invokeMethod<void>('djiLogout');
Future<void> refreshDjiAccountState() => _methods.invokeMethod<void>('getDjiAccountState');
} }
+123 -8
View File
@@ -4,29 +4,144 @@ import 'uploader.dart';
enum RegistrationState { idle, registering, success, failed } enum RegistrationState { idle, registering, success, failed }
/// Live aircraft/session state, shared by the Go Fly launch screen and the /// Camera capture family, mirrors the right-rail Photo/Video/Pano switch.
/// Flight Control overlay. [_HomePageState] owns the DJI/uploader plumbing and enum CaptureMode { photo, video, pano }
/// pushes updates here; the screens observe it via [AnimatedBuilder].
/// Exposure program — Auto (PROGRAM) vs Pro (MANUAL) tab on Camera settings.
enum ExposureProgram { auto, pro }
/// A media file listed off the aircraft's SD card by the native MediaManager.
class MediaItem {
MediaItem({
required this.index,
required this.fileName,
required this.isVideo,
this.durationSeconds,
this.sizeBytes,
this.createdMs,
this.thumbPath,
this.localPath,
});
final int index;
final String fileName;
final bool isVideo;
final int? durationSeconds;
final int? sizeBytes;
final int? createdMs;
/// On-disk path of the fetched thumbnail (cache dir), once available.
String? thumbPath;
/// On-disk path of the fully downloaded original, once available.
String? localPath;
String? get durationLabel {
if (durationSeconds == null) return null;
final int s = durationSeconds!;
final String mm = (s ~/ 60).toString();
final String ss = (s % 60).toString().padLeft(2, '0');
return '$mm:$ss';
}
factory MediaItem.fromMap(Map<String, dynamic> m) => MediaItem(
index: (m['index'] as num).toInt(),
fileName: (m['fileName'] as String?) ?? 'file',
isVideo: (m['type'] as String?) == 'video',
durationSeconds: (m['durationSeconds'] as num?)?.toInt(),
sizeBytes: (m['sizeBytes'] as num?)?.toInt(),
createdMs: (m['createdMs'] as num?)?.toInt(),
);
}
/// Live aircraft/session state, shared across every screen. `_HomePageState`
/// owns the DJI/uploader plumbing and pushes updates here; the screens observe
/// it via [AnimatedBuilder]. Call [bump] after a batch of field writes.
class FlightModel extends ChangeNotifier { class FlightModel extends ChangeNotifier {
// ── SDK / registration / connection ────────────────────────────────────────
String sdkVersion = ''; String sdkVersion = '';
RegistrationState registration = RegistrationState.idle; RegistrationState registration = RegistrationState.idle;
String? registrationError; String? registrationError;
bool connected = false; bool connected = false;
String? model; String? model;
String? firmwareVersion;
// ── Flight controller telemetry ────────────────────────────────────────────
int? satellites; int? satellites;
int? gpsSignalLevel; // 0..5
bool? isFlying; bool? isFlying;
bool? motorsOn;
String? flightMode; String? flightMode;
double? altitude; double? altitude; // m, relative to home
double? latitude; double? latitude;
double? longitude; double? longitude;
// Phone's own GPS (independent of the drone's fix above); streamed to the double? homeLatitude;
// server as a location fallback for the Web App's automatic bounding box. double? homeLongitude;
double? homeDistance; // m, aircraft ↔ home
double? horizontalSpeed; // m/s
double? verticalSpeed; // m/s (up positive)
double? heading; // deg
int? flightTimeSeconds;
double? goHomeHeight; // m
// Phone's own GPS (independent of the drone's fix); streamed to the server as
// a location fallback for the Web App's automatic bounding box.
double? phoneLatitude; double? phoneLatitude;
double? phoneLongitude; double? phoneLongitude;
int? batteryPercent;
// ── Battery ─────────────────────────────────────────────────────────────────
int? batteryPercent;
double? batteryVoltage; // V
double? batteryTemperature; // °C
// ── Camera ──────────────────────────────────────────────────────────────────
CaptureMode captureMode = CaptureMode.video;
String? shootPhotoMode; // SINGLE / HDR / BURST / AEB / INTERVAL / PANORAMA
bool isRecording = false;
int recordSeconds = 0;
bool sdInserted = false;
int? sdRemainingMB;
int? sdPhotoCount;
int? sdVideoCount;
// ── Exposure ────────────────────────────────────────────────────────────────
ExposureProgram exposureProgram = ExposureProgram.auto;
String? iso; // "100", "AUTO", …
String? shutter; // "1/240"
String? aperture; // "f/2.8"
String? ev; // "-0.3"
String? whiteBalance; // "5200K" / "AUTO"
List<double>? histogram; // 0..1 normalized bins
// ── Gimbal ──────────────────────────────────────────────────────────────────
double? gimbalPitch; // deg (90..30-ish)
double? gimbalRoll;
double? gimbalYaw;
// ── Flight settings (Safety / Control) ──────────────────────────────────────
int? maxHeight; // m
int? maxRadius; // m
bool? maxRadiusEnabled;
int? rthHeight; // m
String? obstacleAvoidance; // "On" / "Bypass" / "Off"
bool? noviceMode;
bool arHomePoint = true; // client-side toggle (AR overlay)
// ── Missions ────────────────────────────────────────────────────────────────
String missionState = 'idle'; // idle / ready / uploading / executing / …
bool missionRunning = false;
bool tracking = false; // ActiveTrack engaged
String? missionError;
// ── On-drone media ──────────────────────────────────────────────────────────
List<MediaItem> media = <MediaItem>[];
bool mediaLoading = false;
// ── DJI account (optional, in addition to PilotVault) ───────────────────────
String djiAccountState = 'unknown'; // notLoggedIn / tokenOutOfDate / authorized / …
String? djiAccountUser;
// ── Telemetry upload channel ────────────────────────────────────────────────
UploadStatus upload = UploadStatus.disabled; UploadStatus upload = UploadStatus.disabled;
bool get registered => registration == RegistrationState.success; bool get registered => registration == RegistrationState.success;
+139 -7
View File
@@ -9,9 +9,13 @@ import 'flight_model.dart';
import 'login_page.dart'; import 'login_page.dart';
import 'pb_auth.dart'; import 'pb_auth.dart';
import 'theme.dart'; import 'theme.dart';
import 'ui/academy_page.dart';
import 'ui/album_page.dart'; import 'ui/album_page.dart';
import 'ui/flight_control_page.dart'; import 'ui/flight_control_page.dart';
import 'ui/go_fly_page.dart'; import 'ui/flight_logs_page.dart';
import 'ui/home_page.dart';
import 'ui/profile_page.dart';
import 'ui/routes_page.dart';
import 'uploader.dart'; import 'uploader.dart';
Future<void> main() async { Future<void> main() async {
@@ -148,6 +152,7 @@ class _HomePageState extends State<HomePage> {
case 'connection': case 'connection':
_model.connected = event['connected'] as bool? ?? false; _model.connected = event['connected'] as bool? ?? false;
_model.model = event['model'] as String?; _model.model = event['model'] as String?;
_model.firmwareVersion = event['firmware'] as String?;
if (!_model.connected) _clearTelemetry(); if (!_model.connected) _clearTelemetry();
_model.bump(); _model.bump();
break; break;
@@ -156,11 +161,20 @@ class _HomePageState extends State<HomePage> {
// repopulates values _clearTelemetry() just wiped, leaving stale readings. // repopulates values _clearTelemetry() just wiped, leaving stale readings.
if (!_model.connected) break; if (!_model.connected) break;
_model.satellites = event['satelliteCount'] as int?; _model.satellites = event['satelliteCount'] as int?;
_model.gpsSignalLevel = event['gpsSignalLevel'] as int?;
_model.isFlying = event['isFlying'] as bool?; _model.isFlying = event['isFlying'] as bool?;
_model.motorsOn = event['areMotorsOn'] as bool?;
_model.flightMode = event['flightMode'] as String?; _model.flightMode = event['flightMode'] as String?;
_model.altitude = (event['altitude'] as num?)?.toDouble(); _model.altitude = (event['altitude'] as num?)?.toDouble();
_model.latitude = (event['latitude'] as num?)?.toDouble(); _model.latitude = (event['latitude'] as num?)?.toDouble();
_model.longitude = (event['longitude'] as num?)?.toDouble(); _model.longitude = (event['longitude'] as num?)?.toDouble();
_model.homeLatitude = (event['homeLatitude'] as num?)?.toDouble();
_model.homeLongitude = (event['homeLongitude'] as num?)?.toDouble();
_model.homeDistance = (event['homeDistance'] as num?)?.toDouble();
_model.horizontalSpeed = (event['horizontalSpeed'] as num?)?.toDouble();
_model.verticalSpeed = (event['verticalSpeed'] as num?)?.toDouble();
_model.heading = (event['heading'] as num?)?.toDouble();
_model.goHomeHeight = (event['goHomeHeight'] as num?)?.toDouble();
_model.bump(); _model.bump();
break; break;
case 'battery': case 'battery':
@@ -168,19 +182,82 @@ class _HomePageState extends State<HomePage> {
// last percentage (the reported "disconnected but still 42%" bug). // last percentage (the reported "disconnected but still 42%" bug).
if (!_model.connected) break; if (!_model.connected) break;
_model.batteryPercent = event['percent'] as int?; _model.batteryPercent = event['percent'] as int?;
_model.batteryVoltage = (event['voltage'] as num?)?.toDouble();
_model.batteryTemperature = (event['temperature'] as num?)?.toDouble();
_model.bump(); _model.bump();
break; break;
case 'camera':
if (!_model.connected) break;
_model.isRecording = event['isRecording'] as bool? ?? false;
_model.recordSeconds = (event['recordingTimeSeconds'] as num?)?.toInt() ?? _model.recordSeconds;
_model.bump();
break;
case 'exposure':
if (!_model.connected) break;
_model.iso = event['iso'] as String?;
_model.shutter = event['shutter'] as String?;
_model.aperture = event['aperture'] as String?;
_model.ev = event['ev'] as String?;
_model.bump();
break;
case 'gimbal':
if (!_model.connected) break;
_model.gimbalPitch = (event['pitch'] as num?)?.toDouble();
_model.gimbalRoll = (event['roll'] as num?)?.toDouble();
_model.gimbalYaw = (event['yaw'] as num?)?.toDouble();
_model.bump();
break;
case 'djiAccount':
_model.djiAccountState = event['state'] as String? ?? 'unknown';
_model.djiAccountUser = event['user'] as String?;
_model.bump();
break;
case 'mission':
_model.missionState = event['state'] as String? ?? 'idle';
_model.tracking = _model.missionState == 'tracking' || _model.missionState == 'quickshot';
_model.missionRunning = _model.missionState != 'idle';
_model.bump();
break;
case 'mediaList':
final List<dynamic> files = (event['files'] as List<dynamic>?) ?? const <dynamic>[];
_model.media = files
.map((dynamic e) => MediaItem.fromMap(Map<String, dynamic>.from(e as Map)))
.toList();
_model.mediaLoading = false;
_model.bump();
break;
case 'mediaDownload':
final int idx = (event['index'] as num?)?.toInt() ?? -1;
final String? path = event['path'] as String?;
if (idx >= 0 && idx < _model.media.length && path != null) {
_model.media[idx].localPath = path;
_model.bump();
}
break;
} }
} }
void _clearTelemetry() { void _clearTelemetry() {
_model.satellites = null; _model.satellites = null;
_model.gpsSignalLevel = null;
_model.isFlying = null; _model.isFlying = null;
_model.motorsOn = null;
_model.flightMode = null; _model.flightMode = null;
_model.altitude = null; _model.altitude = null;
_model.latitude = null; _model.latitude = null;
_model.longitude = null; _model.longitude = null;
_model.homeLatitude = null;
_model.homeLongitude = null;
_model.homeDistance = null;
_model.horizontalSpeed = null;
_model.verticalSpeed = null;
_model.heading = null;
_model.batteryPercent = null; _model.batteryPercent = null;
_model.batteryVoltage = null;
_model.batteryTemperature = null;
_model.isRecording = false;
_model.recordSeconds = 0;
_model.firmwareVersion = null;
} }
Future<void> _register() async { Future<void> _register() async {
@@ -229,11 +306,19 @@ class _HomePageState extends State<HomePage> {
} }
final Map<String, dynamic> tel = <String, dynamic>{'type': 'telemetry'}; final Map<String, dynamic> tel = <String, dynamic>{'type': 'telemetry'};
if (_model.satellites != null) tel['satelliteCount'] = _model.satellites; if (_model.satellites != null) tel['satelliteCount'] = _model.satellites;
if (_model.gpsSignalLevel != null) tel['gpsSignalLevel'] = _model.gpsSignalLevel;
if (_model.isFlying != null) tel['isFlying'] = _model.isFlying; if (_model.isFlying != null) tel['isFlying'] = _model.isFlying;
if (_model.motorsOn != null) tel['areMotorsOn'] = _model.motorsOn;
if (_model.flightMode != null) tel['flightMode'] = _model.flightMode; if (_model.flightMode != null) tel['flightMode'] = _model.flightMode;
if (_model.altitude != null) tel['altitude'] = _model.altitude; if (_model.altitude != null) tel['altitude'] = _model.altitude;
if (_model.latitude != null) tel['latitude'] = _model.latitude; if (_model.latitude != null) tel['latitude'] = _model.latitude;
if (_model.longitude != null) tel['longitude'] = _model.longitude; if (_model.longitude != null) tel['longitude'] = _model.longitude;
if (_model.homeLatitude != null) tel['homeLatitude'] = _model.homeLatitude;
if (_model.homeLongitude != null) tel['homeLongitude'] = _model.homeLongitude;
if (_model.homeDistance != null) tel['homeDistance'] = _model.homeDistance;
if (_model.horizontalSpeed != null) tel['horizontalSpeed'] = _model.horizontalSpeed;
if (_model.verticalSpeed != null) tel['verticalSpeed'] = _model.verticalSpeed;
if (_model.heading != null) tel['heading'] = _model.heading;
if (_model.phoneLatitude != null) tel['phoneLatitude'] = _model.phoneLatitude; if (_model.phoneLatitude != null) tel['phoneLatitude'] = _model.phoneLatitude;
if (_model.phoneLongitude != null) tel['phoneLongitude'] = _model.phoneLongitude; if (_model.phoneLongitude != null) tel['phoneLongitude'] = _model.phoneLongitude;
if (tel.length > 1) events.add(tel); if (tel.length > 1) events.add(tel);
@@ -297,25 +382,72 @@ class _HomePageState extends State<HomePage> {
void _goFly() { void _goFly() {
Navigator.of(context).push(MaterialPageRoute<void>( Navigator.of(context).push(MaterialPageRoute<void>(
builder: (_) => FlightControlPage(model: _model), builder: (_) => FlightControlPage(model: _model, dji: _dji),
)); ));
} }
void _openAlbum() { void _openAlbum() {
Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => const AlbumPage())); Navigator.of(context).push(MaterialPageRoute<void>(
builder: (_) => AlbumPage(model: _model, dji: _dji),
));
} }
void _onTile(String tile) => _snack('$tile — coming soon'); /// The disconnected-home "Connect aircraft" button: register first if needed
/// (registration auto-starts a product scan on success), else just scan.
Future<void> _connectFlow() async {
if (!auth.isAuthed) {
_snack('Sign in to connect an aircraft');
await _openLogin();
return;
}
if (!_model.registered) {
await _register();
} else {
await _connect();
}
}
void _onTile(String tile) {
switch (tile) {
case 'Album':
_openAlbum();
break;
case 'Academy':
Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => const AcademyPage()));
break;
case 'Routes':
Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => const RoutesPage()));
break;
case 'Flight logs':
Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => const FlightLogsPage()));
break;
default:
_snack('$tile — coming soon');
}
}
/// The avatar opens the pilot profile (PilotVault + optional DJI account,
/// stats, library shortcuts). Technical controls live behind its App settings.
void _openProfile() {
Navigator.of(context).push(MaterialPageRoute<void>(
builder: (_) => ProfilePage(
model: _model,
dji: _dji,
onAppSettings: _openSettings,
onSignIn: _openLogin,
),
));
}
// ── UI ─────────────────────────────────────────────────────────────────── // ── UI ───────────────────────────────────────────────────────────────────
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GoFlyPage( return HomeScreen(
model: _model, model: _model,
onGoFly: _goFly, onGoFly: _goFly,
onOpenAlbum: _openAlbum, onConnect: _connectFlow,
onSettings: _openSettings, onSettings: _openProfile,
onTile: _onTile, onTile: _onTile,
); );
} }
+104
View File
@@ -0,0 +1,104 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../theme.dart';
import 'pv_icons.dart';
/// Academy — mirrors the v2 "Academy" mockup. A featured card plus a list of
/// tutorial / manual / tips entries that open reference material in the browser.
class AcademyPage extends StatelessWidget {
const AcademyPage({super.key});
static const List<(String, String, String, String)> _cards = <(String, String, String, String)>[
('academy', 'Flight tutorials', '6 lessons', 'https://www.dji.com/dk/dji-fly'),
('shield', 'Product manuals', 'Air 3 · RC 2', 'https://www.pix-pro.com/blog/dji-fly-guide-part1'),
('sun', 'Flight tips', 'Wind & weather', 'https://www.skyzr.com/en/dji/dji-fly-app/the-ultimate-dji-fly-app-guide/'),
];
Future<void> _open(BuildContext context, String url) async {
final Uri uri = Uri.parse(url);
if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Could not open $url')));
}
}
}
@override
Widget build(BuildContext context) {
final PVScheme s = PVScheme.of(context);
return Scaffold(
backgroundColor: s.bgApp,
body: SafeArea(
child: ListView(
children: <Widget>[
Padding(
padding: const EdgeInsets.fromLTRB(12, 6, 20, 14),
child: Row(children: <Widget>[
IconButton(onPressed: () => Navigator.of(context).maybePop(), icon: PVIcon('chevronLeft', size: 24, color: s.textSecondary)),
Text('Academy', style: TextStyle(fontFamily: PV.fontSans, fontSize: 20, fontWeight: FontWeight.w700, letterSpacing: -0.4, color: s.textPrimary)),
]),
),
GestureDetector(
onTap: () => _open(context, 'https://www.dji.com/dk/dji-fly'),
child: Container(
height: 132,
margin: const EdgeInsets.fromLTRB(20, 0, 20, 16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(color: s.border),
gradient: const LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: <Color>[Color(0xFF26406E), Color(0xFF0F1E3D)]),
),
child: Stack(children: <Widget>[
const Positioned(
left: 16, bottom: 16, right: 64,
child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: <Widget>[
Text('GETTING STARTED', style: TextStyle(fontFamily: PV.fontMono, fontSize: 10, letterSpacing: 1.4, color: Color(0xB3EAF0FA))),
SizedBox(height: 3),
Text('Your first flight in 5 minutes', style: TextStyle(fontFamily: PV.fontSans, fontSize: 17, fontWeight: FontWeight.w700, color: Color(0xFFEAF0FA))),
]),
),
Positioned(
top: 16, right: 16,
child: Container(
width: 40, height: 40,
decoration: const BoxDecoration(color: Color(0x29FFFFFF), shape: BoxShape.circle),
alignment: Alignment.center,
child: const PVIcon('play', size: 18, color: Colors.white, fill: true),
),
),
]),
),
),
for (final (String ic, String t, String sub, String url) in _cards)
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 10),
child: GestureDetector(
onTap: () => _open(context, url),
child: Container(
height: 62,
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(color: s.surface, borderRadius: BorderRadius.circular(14), border: Border.all(color: s.border), boxShadow: s.shadowXs),
child: Row(children: <Widget>[
Container(
width: 38, height: 38,
decoration: BoxDecoration(color: s.accentSoft, borderRadius: BorderRadius.circular(10)),
alignment: Alignment.center,
child: PVIcon(ic, size: 19, color: s.accentSoftFg),
),
const SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[
Text(t, style: TextStyle(fontFamily: PV.fontSans, fontSize: 14.5, fontWeight: FontWeight.w600, color: s.textPrimary)),
Text(sub, style: TextStyle(fontFamily: PV.fontMono, fontSize: 11, color: s.textSecondary)),
])),
PVIcon('chevronRight', size: 18, color: s.textTertiary),
]),
),
),
),
],
),
),
);
}
}
+155 -86
View File
@@ -1,12 +1,22 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../dji_service.dart';
import '../flight_model.dart';
import '../theme.dart'; import '../theme.dart';
import 'pv_icons.dart';
/// Portrait media grid — mirrors the ui_kit/fly "Album" mockup. Media is /// Portrait media grid — mirrors the v2 "Album" mockup. Backed by the aircraft's
/// placeholder content (the app has no on-device gallery source yet); the /// SD card through the SDK MediaManager: refreshes the list on open, lazily
/// layout, filters and badges match the design. /// fetches thumbnails, and downloads originals on tap. Falls back to an empty
/// state when no aircraft/media is present.
class AlbumPage extends StatefulWidget { class AlbumPage extends StatefulWidget {
const AlbumPage({super.key}); const AlbumPage({super.key, required this.model, required this.dji});
final FlightModel model;
final DjiService dji;
@override @override
State<AlbumPage> createState() => _AlbumPageState(); State<AlbumPage> createState() => _AlbumPageState();
@@ -15,14 +25,57 @@ class AlbumPage extends StatefulWidget {
class _AlbumPageState extends State<AlbumPage> { class _AlbumPageState extends State<AlbumPage> {
static const List<String> _filters = <String>['All', 'Photos', 'Videos', 'Pano']; static const List<String> _filters = <String>['All', 'Photos', 'Videos', 'Pano'];
int _active = 0; int _active = 0;
final Set<int> _thumbRequested = <int>{};
// (isVideo, duration) — placeholder set from the mockup. DjiService get _dji => widget.dji;
static const List<(bool, String?)> _media = <(bool, String?)>[ FlightModel get _m => widget.model;
(true, '0:24'), (false, null), (false, null),
(true, '1:12'), (false, null), (false, null), @override
(false, null), (true, '0:08'), (false, null), void initState() {
(false, null), (true, '0:31'), (false, null), super.initState();
]; _refresh();
}
Future<void> _refresh() async {
if (!_m.connected) return;
setState(() => _m.mediaLoading = true);
try {
await _dji.refreshMediaList();
} catch (e) {
if (mounted) {
setState(() => _m.mediaLoading = false);
_snack('Media: ${e is PlatformException ? (e.message ?? e.code) : e}');
}
}
}
void _snack(String msg) {
ScaffoldMessenger.of(context)
..clearSnackBars()
..showSnackBar(SnackBar(content: Text(msg)));
}
Future<void> _ensureThumb(MediaItem item) async {
if (item.thumbPath != null || _thumbRequested.contains(item.index)) return;
_thumbRequested.add(item.index);
try {
final String? path = await _dji.fetchThumbnail(item.index);
if (path != null && mounted) setState(() => item.thumbPath = path);
} catch (_) {
// leave placeholder icon
}
}
List<MediaItem> get _visible {
switch (_active) {
case 1:
return _m.media.where((MediaItem m) => !m.isVideo).toList();
case 2:
return _m.media.where((MediaItem m) => m.isVideo).toList();
default:
return _m.media;
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -30,14 +83,17 @@ class _AlbumPageState extends State<AlbumPage> {
return Scaffold( return Scaffold(
backgroundColor: s.bgApp, backgroundColor: s.bgApp,
body: SafeArea( body: SafeArea(
child: Column( child: AnimatedBuilder(
crossAxisAlignment: CrossAxisAlignment.start, animation: _m,
children: <Widget>[ builder: (BuildContext context, _) => Column(
_header(s), crossAxisAlignment: CrossAxisAlignment.start,
_filterBar(s), children: <Widget>[
const SizedBox(height: 14), _header(s),
Expanded(child: _grid(s)), _filterBar(s),
], const SizedBox(height: 14),
Expanded(child: _body(s)),
],
),
), ),
), ),
); );
@@ -46,20 +102,16 @@ class _AlbumPageState extends State<AlbumPage> {
Widget _header(PVScheme s) { Widget _header(PVScheme s) {
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(12, 6, 20, 12), padding: const EdgeInsets.fromLTRB(12, 6, 20, 12),
child: Row( child: Row(children: <Widget>[
children: <Widget>[ IconButton(
IconButton( onPressed: () => Navigator.of(context).maybePop(),
onPressed: () => Navigator.of(context).maybePop(), icon: PVIcon('chevronLeft', size: 24, color: s.textSecondary),
icon: Icon(Icons.chevron_left, size: 26, color: s.textSecondary), ),
), Text('Album', style: TextStyle(fontFamily: PV.fontSans, fontSize: 20, fontWeight: FontWeight.w700, letterSpacing: -0.4, color: s.textPrimary)),
Text( const Spacer(),
'Album', IconButton(onPressed: _refresh, icon: PVIcon('search', size: 19, color: s.textSecondary)),
style: TextStyle(fontFamily: PV.fontSans, fontSize: 20, fontWeight: FontWeight.w700, letterSpacing: -0.4, color: s.textPrimary), Text('${_m.media.length} items', style: TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: s.textTertiary)),
), ]),
const Spacer(),
Text('${_media.length} items', style: TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: s.textTertiary)),
],
),
); );
} }
@@ -78,19 +130,9 @@ class _AlbumPageState extends State<AlbumPage> {
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12), padding: const EdgeInsets.symmetric(horizontal: 12),
alignment: Alignment.center, alignment: Alignment.center,
decoration: BoxDecoration( decoration: BoxDecoration(color: active ? s.accent : s.surfaceInset, borderRadius: BorderRadius.circular(999)),
color: active ? s.accent : s.surfaceInset, child: Text(_filters[i],
borderRadius: BorderRadius.circular(999), style: TextStyle(fontFamily: PV.fontSans, fontSize: 12.5, fontWeight: FontWeight.w600, color: active ? Colors.white : s.textSecondary)),
),
child: Text(
_filters[i],
style: TextStyle(
fontFamily: PV.fontSans,
fontSize: 12.5,
fontWeight: FontWeight.w600,
color: active ? Colors.white : s.textSecondary,
),
),
), ),
); );
}, },
@@ -98,48 +140,75 @@ class _AlbumPageState extends State<AlbumPage> {
); );
} }
Widget _grid(PVScheme s) { Widget _body(PVScheme s) {
if (_m.mediaLoading) {
return Center(child: CircularProgressIndicator(color: s.accent));
}
final List<MediaItem> items = _visible;
if (items.isEmpty) {
return Center(
child: Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
PVIcon('album', size: 40, stroke: 1.4, color: s.textTertiary),
const SizedBox(height: 12),
Text(_m.connected ? 'No media on the SD card' : 'Connect an aircraft to browse its media',
style: TextStyle(fontFamily: PV.fontSans, fontSize: 14, color: s.textSecondary)),
const SizedBox(height: 12),
if (_m.connected) OutlinedButton(onPressed: _refresh, child: const Text('Refresh')),
]),
);
}
return GridView.builder( return GridView.builder(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3, crossAxisSpacing: 6, mainAxisSpacing: 6),
crossAxisCount: 3, itemCount: items.length,
crossAxisSpacing: 6, itemBuilder: (BuildContext context, int i) => _cell(s, items[i], i),
mainAxisSpacing: 6,
),
itemCount: _media.length,
itemBuilder: (BuildContext context, int i) {
final (bool isVideo, String? dur) = _media[i];
return ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: <Color>[s.surface2, s.surfaceInset],
transform: GradientRotation((140 + i * 14) * 3.1415926 / 180),
),
border: Border.all(color: s.border),
borderRadius: BorderRadius.circular(10),
),
child: Stack(
children: <Widget>[
Center(child: Icon(isVideo ? Icons.videocam_outlined : Icons.image_outlined, size: 20, color: s.textTertiary)),
if (dur != null)
Positioned(
right: 6,
bottom: 5,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(color: const Color(0xB30B1730), borderRadius: BorderRadius.circular(5)),
child: Text(dur, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 9.5, color: Colors.white)),
),
),
],
),
),
);
},
); );
} }
Widget _cell(PVScheme s, MediaItem item, int i) {
_ensureThumb(item);
final bool hasThumb = item.thumbPath != null && File(item.thumbPath!).existsSync();
return GestureDetector(
onTap: () => _open(item),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft, end: Alignment.bottomRight,
colors: <Color>[s.surface2, s.surfaceInset],
transform: GradientRotation((140 + i * 14) * 3.1415926 / 180),
),
border: Border.all(color: s.border),
borderRadius: BorderRadius.circular(10),
),
child: Stack(fit: StackFit.expand, children: <Widget>[
if (hasThumb)
Image.file(File(item.thumbPath!), fit: BoxFit.cover)
else
Center(child: PVIcon(item.isVideo ? 'video' : 'image', size: 20, stroke: 1.6, color: s.textTertiary)),
if (item.durationLabel != null)
Positioned(
right: 6, bottom: 5,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(color: const Color(0xB30B1730), borderRadius: BorderRadius.circular(5)),
child: Text(item.durationLabel!, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 9.5, color: Colors.white)),
),
),
]),
),
),
);
}
Future<void> _open(MediaItem item) async {
_snack('Downloading ${item.fileName}');
try {
final String? path = await _dji.downloadMedia(item.index);
if (mounted && path != null) _snack('Saved to $path');
} catch (e) {
if (mounted) _snack('Download: ${e is PlatformException ? (e.message ?? e.code) : e}');
}
}
} }
+236
View File
@@ -0,0 +1,236 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../dji_service.dart';
import '../flight_model.dart';
import '../theme.dart';
import 'flight_overlay_scaffold.dart';
import 'pv_icons.dart';
/// Pro exposure controls — mirrors the v2 "Camera settings" mockup. Auto/Pro
/// tabs switch exposure program; the five dials (ISO / shutter / aperture / EV /
/// WB) each drive the matching SDK setter, and the value scrubber picks a value.
class CameraSettingsPage extends StatefulWidget {
const CameraSettingsPage({super.key, required this.model, required this.dji});
final FlightModel model;
final DjiService dji;
@override
State<CameraSettingsPage> createState() => _CameraSettingsPageState();
}
class _CameraSettingsPageState extends State<CameraSettingsPage> {
DjiService get _dji => widget.dji;
FlightModel get _m => widget.model;
// (key, icon, values). Values mirror the kit scrubber; the SDK rejects any it
// doesn't support (surfaced as a snackbar), so a superset is safe.
static const List<(String, String, List<String>)> _dials = <(String, String, List<String>)>[
('ISO', 'iso', <String>['AUTO', '100', '200', '400', '800', '1600', '3200', '6400']),
('Shutter', 'shutter', <String>['1/2000', '1/1000', '1/500', '1/240', '1/120', '1/60', '1/30', '1/15', '1/8']),
('Aperture', 'aperture', <String>['f/2.8', 'f/4', 'f/5.6', 'f/8', 'f/11']),
('EV', 'ev', <String>['-2.0', '-1.3', '-0.7', '-0.3', '0.0', '+0.3', '+0.7', '+1.3', '+2.0']),
('WB', 'wb', <String>['AUTO', '2700K', '4000K', '5200K', '5800K', '6500K']),
];
int _active = 0; // selected dial
bool get _pro => _m.exposureProgram == ExposureProgram.pro;
void _snack(String msg) {
ScaffoldMessenger.of(context)
..clearSnackBars()
..showSnackBar(SnackBar(content: Text(msg)));
}
Future<void> _run(String label, Future<void> Function() action) async {
try {
await action();
} catch (e) {
_snack('$label: ${e is PlatformException ? (e.message ?? e.code) : e}');
}
}
String? _valueFor(String key) => switch (key) {
'ISO' => _m.iso,
'Shutter' => _m.shutter,
'Aperture' => _m.aperture,
'EV' => _m.ev,
'WB' => _m.whiteBalance,
_ => null,
};
Future<void> _setValue(String key, String value) async {
setState(() {
switch (key) {
case 'ISO':
_m.iso = value;
case 'Shutter':
_m.shutter = value;
case 'Aperture':
_m.aperture = value;
case 'EV':
_m.ev = value;
case 'WB':
_m.whiteBalance = value;
}
});
switch (key) {
case 'ISO':
await _run('ISO', () => _dji.setISO(value));
case 'Shutter':
await _run('Shutter', () => _dji.setShutterSpeed(value));
case 'Aperture':
await _run('Aperture', () => _dji.setAperture(value));
case 'EV':
await _run('EV', () => _dji.setEV(value));
case 'WB':
await _run('White balance', () => _dji.setWhiteBalance(value));
}
}
Future<void> _setProgram(bool pro) async {
setState(() => _m.exposureProgram = pro ? ExposureProgram.pro : ExposureProgram.auto);
await _run('Exposure', () => _dji.setExposureProgram(pro ? 'pro' : 'auto'));
}
@override
Widget build(BuildContext context) {
return FlightOverlayScaffold(
title: 'Camera settings',
trailing: _autoProTabs(),
body: Column(
children: <Widget>[
Align(
alignment: Alignment.centerRight,
child: Container(
width: 132, height: 48,
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(10)),
child: const CustomPaint(size: Size.infinite, painter: _HistogramPainter()),
),
),
const Spacer(),
_dialRail(),
],
),
);
}
Widget _autoProTabs() {
return Container(
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(10)),
child: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
_tab('Auto', !_pro, () => _setProgram(false)),
_tab('Pro', _pro, () => _setProgram(true)),
]),
);
}
Widget _tab(String label, bool active, VoidCallback onTap) {
return GestureDetector(
onTap: onTap,
child: Container(
height: 28,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(color: active ? Glass.accent : Colors.transparent, borderRadius: BorderRadius.circular(7)),
alignment: Alignment.center,
child: Text(label, style: const TextStyle(fontFamily: PV.fontSans, fontSize: 12, fontWeight: FontWeight.w700, color: Glass.ink)),
),
);
}
Widget _dialRail() {
final (String key, _, List<String> values) = _dials[_active];
final String? current = _valueFor(key);
return Container(
padding: const EdgeInsets.fromLTRB(8, 12, 8, 12),
decoration: BoxDecoration(
color: Glass.pill,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0x1AFFFFFF)),
),
child: Column(children: <Widget>[
Row(mainAxisAlignment: MainAxisAlignment.spaceAround, children: <Widget>[
for (int i = 0; i < _dials.length; i++) _dial(i),
]),
const SizedBox(height: 12),
Opacity(
opacity: _pro ? 1 : 0.4,
child: IgnorePointer(
ignoring: !_pro,
child: SizedBox(
height: 30,
child: ListView(
scrollDirection: Axis.horizontal,
children: <Widget>[
for (final String v in values) _scrubValue(key, v, v == current),
],
),
),
),
),
]),
);
}
Widget _dial(int i) {
final (String key, String icon, _) = _dials[i];
final bool sel = i == _active;
final String value = _valueFor(key) ?? '';
return GestureDetector(
onTap: () => setState(() => _active = i),
child: Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
PVIcon(icon, size: 17, color: sel ? const Color(0xFF8FB4F6) : const Color(0xFF8FA0BE)),
const SizedBox(height: 4),
Text(key, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 9.5, letterSpacing: 0.8, color: Color(0x99EAF0FA))),
Text(value, style: TextStyle(fontFamily: PV.fontMono, fontSize: 15, fontWeight: FontWeight.w700, color: sel ? const Color(0xFF8FB4F6) : Glass.ink)),
]),
);
}
Widget _scrubValue(String key, String v, bool sel) {
return GestureDetector(
onTap: () => _setValue(key, v),
child: Container(
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text(v,
style: TextStyle(
fontFamily: PV.fontMono,
fontSize: sel ? 14 : 12,
fontWeight: sel ? FontWeight.w700 : FontWeight.w400,
color: sel ? const Color(0xFF5B93F5) : const Color(0x99EAF0FA),
)),
),
);
}
}
class _HistogramPainter extends CustomPainter {
const _HistogramPainter();
@override
void paint(Canvas canvas, Size size) {
final Path p = Path()
..moveTo(0, size.height)
..quadraticBezierTo(size.width * 0.2, size.height * 0.2, size.width * 0.4, size.height * 0.6)
..quadraticBezierTo(size.width * 0.6, size.height * 0.05, size.width * 0.8, size.height * 0.5)
..quadraticBezierTo(size.width * 0.9, size.height * 0.8, size.width, size.height * 0.9);
canvas.drawPath(
Path.from(p)
..lineTo(size.width, size.height)
..lineTo(0, size.height)
..close(),
Paint()..color = const Color(0x407FE0B0),
);
canvas.drawPath(p, Paint()
..color = const Color(0xFF7FE0B0)
..style = PaintingStyle.stroke
..strokeWidth = 1.4);
}
@override
bool shouldRepaint(covariant _HistogramPainter oldDelegate) => false;
}
+158
View File
@@ -0,0 +1,158 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../dji_service.dart';
import '../flight_model.dart';
import '../theme.dart';
import 'flight_overlay_scaffold.dart';
import 'pv_icons.dart';
/// Capture-mode selector — mirrors the v2 "Capture modes" mockup. Photo/Video
/// modes drive the camera directly; QuickShots + Intelligent modes start the
/// matching SDK mission (Phase 5 native — until then they report "unavailable").
class CaptureModesPage extends StatefulWidget {
const CaptureModesPage({super.key, required this.model, required this.dji});
final FlightModel model;
final DjiService dji;
@override
State<CaptureModesPage> createState() => _CaptureModesPageState();
}
class _CaptureModesPageState extends State<CaptureModesPage> {
DjiService get _dji => widget.dji;
FlightModel get _m => widget.model;
// (icon, label, wire) — wire drives the SDK call per group.
static const List<(String, String, List<(String, String, String)>)> _groups =
<(String, String, List<(String, String, String)>)>[
('Photo', 'photo', <(String, String, String)>[
('image', 'Single', 'SINGLE'),
('aperture', 'AEB', 'AEB'),
('burst', 'Burst', 'BURST'),
('timer', 'Timed', 'INTERVAL'),
('hdr', 'HDR', 'HDR'),
]),
('Video', 'video', <(String, String, String)>[
('video', 'Normal', 'NORMAL'),
('slowmo', 'Slow-Mo', 'SLOW_MOTION'),
('hyperlapse', 'Hyperlapse', 'HYPERLAPSE'),
]),
('QuickShots', 'quick', <(String, String, String)>[
('dronie', 'Dronie', 'Dronie'),
('rocket', 'Rocket', 'Rocket'),
('circle', 'Circle', 'Circle'),
('helix', 'Helix', 'Helix'),
('boomerang', 'Boomerang', 'Boomerang'),
('asteroid', 'Asteroid', 'Asteroid'),
]),
('Intelligent', 'smart', <(String, String, String)>[
('master', 'MasterShot', 'MasterShot'),
('pano', 'Pano', 'PANORAMA'),
('crosshair', 'Track', 'TRACK'),
]),
];
String? _selected;
@override
void initState() {
super.initState();
_selected = _m.shootPhotoMode ?? 'SINGLE';
}
void _snack(String msg) {
ScaffoldMessenger.of(context)
..clearSnackBars()
..showSnackBar(SnackBar(content: Text(msg)));
}
Future<void> _run(String label, Future<void> Function() action) async {
try {
await action();
} catch (e) {
_snack('$label: ${e is PlatformException ? (e.message ?? e.code) : e}');
}
}
Future<void> _pick(String group, String label, String wire) async {
setState(() => _selected = wire);
switch (group) {
case 'photo':
_m.captureMode = CaptureMode.photo;
_m.shootPhotoMode = wire;
await _run('Photo mode', () => _dji.setCameraMode('photo'));
await _run('Photo mode', () => _dji.setShootPhotoMode(wire));
break;
case 'video':
_m.captureMode = CaptureMode.video;
await _run('Video mode', () => _dji.setCameraMode('video'));
if (wire != 'NORMAL') _snack('$label is applied on the aircraft camera');
break;
case 'quick':
await _run('QuickShot', () => _dji.startQuickShot(wire));
break;
case 'smart':
if (wire == 'PANORAMA') {
_m.captureMode = CaptureMode.photo;
await _run('Pano', () => _dji.setCameraMode('photo'));
await _run('Pano', () => _dji.setShootPhotoMode('PANORAMA'));
} else if (wire == 'MasterShot') {
await _run('MasterShot', () => _dji.startQuickShot('MasterShot'));
} else {
_snack('Track: draw a box around a subject in the flight view');
}
break;
}
if (mounted) _m.bump();
}
@override
Widget build(BuildContext context) {
return FlightOverlayScaffold(
title: 'Capture modes',
body: ListView(
children: <Widget>[
for (final (String label, String group, List<(String, String, String)> items) in _groups) ...<Widget>[
Padding(
padding: const EdgeInsets.only(top: 4, bottom: 8),
child: Text(label.toUpperCase(),
style: const TextStyle(fontFamily: PV.fontMono, fontSize: 10, letterSpacing: 1.4, color: Color(0x8CEAF0FA))),
),
Wrap(spacing: 10, runSpacing: 10, children: <Widget>[
for (final (String ic, String l, String wire) in items) _cell(group, ic, l, wire),
]),
const SizedBox(height: 18),
],
],
),
);
}
Widget _cell(String group, String icon, String label, String wire) {
final bool sel = _selected == wire;
return GestureDetector(
onTap: () => _pick(group, label, wire),
child: SizedBox(
width: 66,
child: Column(children: <Widget>[
Container(
width: 46, height: 46,
decoration: BoxDecoration(
color: sel ? const Color(0x403D7BF0) : const Color(0x0DFFFFFF),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: sel ? const Color(0xFF5B93F5) : Glass.hairline, width: sel ? 2 : 1),
),
alignment: Alignment.center,
child: PVIcon(icon, size: 20, stroke: 1.7, color: sel ? const Color(0xFF8FB4F6) : Glass.ink),
),
const SizedBox(height: 5),
Text(label,
maxLines: 1, overflow: TextOverflow.ellipsis,
style: const TextStyle(fontFamily: PV.fontMono, fontSize: 9.5, color: Color(0xCCEAF0FA))),
]),
),
);
}
}
+339 -252
View File
@@ -3,18 +3,25 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../dji_service.dart';
import '../flight_model.dart'; import '../flight_model.dart';
import '../theme.dart'; import '../theme.dart';
import 'camera_settings_page.dart';
import 'capture_modes_page.dart';
import 'dji_video_view.dart'; import 'dji_video_view.dart';
import 'map_page.dart';
import 'pv_icons.dart';
import 'settings_menu_page.dart';
/// Landscape live-flight overlay — mirrors the ui_kit/fly "Flight control · /// Landscape live-flight overlay — mirrors the v2 "Main flight view" mockup.
/// landscape" mockup. Locks to landscape while shown and restores portrait on /// Live camera feed behind a glass HUD; real telemetry bound throughout, and
/// exit. Camera-feed HUD is composited over a painted placeholder feed; real /// the shutter / mode switch / gimbal slider / RTH / take-off controls issue
/// telemetry (satellites, battery, altitude, mode) is bound where available. /// real SDK commands via [DjiService].
class FlightControlPage extends StatefulWidget { class FlightControlPage extends StatefulWidget {
const FlightControlPage({super.key, required this.model}); const FlightControlPage({super.key, required this.model, required this.dji});
final FlightModel model; final FlightModel model;
final DjiService dji;
@override @override
State<FlightControlPage> createState() => _FlightControlPageState(); State<FlightControlPage> createState() => _FlightControlPageState();
@@ -23,7 +30,12 @@ class FlightControlPage extends StatefulWidget {
class _FlightControlPageState extends State<FlightControlPage> { class _FlightControlPageState extends State<FlightControlPage> {
Timer? _recTimer; Timer? _recTimer;
int _recSeconds = 0; int _recSeconds = 0;
String _mode = 'Video'; // Gimbal pitch slider: fraction 0 (top, +30°) … 1 (bottom, 90°).
double _gimbalFrac = 0.24;
bool _grid = false;
DjiService get _dji => widget.dji;
FlightModel get _m => widget.model;
@override @override
void initState() { void initState() {
@@ -33,9 +45,6 @@ class _FlightControlPageState extends State<FlightControlPage> {
DeviceOrientation.landscapeRight, DeviceOrientation.landscapeRight,
]); ]);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
_recTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted) setState(() => _recSeconds++);
});
} }
@override @override
@@ -46,88 +55,144 @@ class _FlightControlPageState extends State<FlightControlPage> {
super.dispose(); super.dispose();
} }
String get _recLabel { String _fmt(int s) => '${(s ~/ 60).toString().padLeft(2, '0')}:${(s % 60).toString().padLeft(2, '0')}';
final String mm = (_recSeconds ~/ 60).toString().padLeft(2, '0');
final String ss = (_recSeconds % 60).toString().padLeft(2, '0'); void _snack(String msg) {
return '$mm:$ss'; if (!mounted) return;
ScaffoldMessenger.of(context)
..clearSnackBars()
..showSnackBar(SnackBar(content: Text(msg)));
}
Future<void> _run(String label, Future<void> Function() action) async {
try {
await action();
} catch (e) {
_snack('$label failed: ${_reason(e)}');
}
}
String _reason(Object e) => e is PlatformException ? (e.message ?? e.code) : e.toString();
// ── Commands ───────────────────────────────────────────────────────────────
Future<void> _toggleShutter() async {
if (_m.captureMode == CaptureMode.video) {
if (_m.isRecording) {
_stopRecTimer();
await _run('Stop recording', _dji.stopRecordVideo);
} else {
_startRecTimer();
await _run('Start recording', _dji.startRecordVideo);
}
} else {
await _run('Shoot photo', _dji.startShootPhoto);
}
}
void _startRecTimer() {
_recSeconds = 0;
_recTimer?.cancel();
_recTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted) setState(() => _recSeconds++);
});
}
void _stopRecTimer() {
_recTimer?.cancel();
_recTimer = null;
}
Future<void> _setCaptureMode(CaptureMode mode) async {
setState(() => _m.captureMode = mode);
final String wire = mode == CaptureMode.video ? 'video' : 'photo';
await _run('Set camera mode', () => _dji.setCameraMode(wire));
}
Future<void> _rth() async {
final bool ok = await _confirm('Return to Home', 'The aircraft will fly back to its recorded home point and land.');
if (ok) await _run('Return to Home', _dji.startGoHome);
}
Future<void> _toggleTakeoff() async {
if (_m.isFlying == true) {
final bool ok = await _confirm('Land now', 'The aircraft will descend and land at its current position.');
if (ok) await _run('Land', _dji.land);
} else {
final bool ok = await _confirm('Take off', 'The aircraft will take off and hover at ~1.2 m.');
if (ok) await _run('Take off', _dji.takeOff);
}
}
Future<bool> _confirm(String title, String body) async {
final bool? r = await showDialog<bool>(
context: context,
builder: (BuildContext ctx) => AlertDialog(
backgroundColor: const Color(0xFF10203F),
title: Text(title, style: const TextStyle(color: Glass.ink, fontFamily: PV.fontSans, fontWeight: FontWeight.w700)),
content: Text(body, style: const TextStyle(color: Color(0xFF8FA0BE), fontFamily: PV.fontSans)),
actions: <Widget>[
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(title)),
],
),
);
return r ?? false;
}
void _onGimbalDrag(double frac) {
setState(() => _gimbalFrac = frac.clamp(0.0, 1.0));
final double pitch = 30 - _gimbalFrac * 120; // +30 (top) … 90 (bottom)
_run('Gimbal', () => _dji.rotateGimbalPitch(pitch));
}
void _openCaptureModes() {
Navigator.of(context).push(MaterialPageRoute<void>(
builder: (_) => CaptureModesPage(model: _m, dji: _dji),
));
}
void _openCameraSettings() {
Navigator.of(context).push(MaterialPageRoute<void>(
builder: (_) => CameraSettingsPage(model: _m, dji: _dji),
));
}
void _openSettingsMenu() {
Navigator.of(context).push(MaterialPageRoute<void>(
builder: (_) => SettingsMenuPage(model: _m, dji: _dji),
));
}
void _openMap() {
Navigator.of(context).push(MaterialPageRoute<void>(
builder: (_) => MapPage(model: _m, dji: _dji),
));
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final FlightModel m = widget.model;
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFF0A1120), backgroundColor: const Color(0xFF0A1120),
body: AnimatedBuilder( body: AnimatedBuilder(
animation: m, animation: _m,
builder: (BuildContext context, _) { builder: (BuildContext context, _) {
return Stack( return Stack(
children: <Widget>[ children: <Widget>[
// Background: live DJI camera feed when a product is connected,
// painted placeholder otherwise. The HUD layers below paint over
// it since they come later in the stack.
Positioned.fill( Positioned.fill(
child: m.connected child: _m.connected
? const DjiVideoView() ? const DjiVideoView()
: const CustomPaint(painter: _FeedPainter()), : const CustomPaint(painter: _FeedPainter()),
), ),
if (_grid) const Positioned.fill(child: IgnorePointer(child: CustomPaint(painter: _GridPainter()))),
// Center reticle const Center(child: PVIcon('crosshair', size: 30, stroke: 1.1, color: Color(0xB3FFFFFF))),
const Center(child: Icon(Icons.add, size: 30, color: Color(0xB3FFFFFF))), Positioned(top: 12, left: 14, right: 14, child: _topBar()),
Positioned(left: 14, top: 58, child: _leftRail()),
// Top bar Positioned(left: 70, top: 58, bottom: 96, child: _gimbalSlider()),
Positioned( Positioned(right: 16, top: 0, bottom: 0, child: Center(child: _cameraControls())),
top: 12,
left: 14,
right: 14,
child: _topBar(m),
),
// Left rail
Positioned(
left: 14,
top: 58,
child: Column(
children: <Widget>[
_sideBtn(Icons.control_camera, active: true),
const SizedBox(height: 10),
_sideBtn(Icons.wb_sunny_outlined),
const SizedBox(height: 10),
_sideBtn(Icons.camera_outlined),
const SizedBox(height: 10),
_sideBtn(Icons.grid_on),
],
),
),
// Gimbal pitch slider
Positioned(
left: 70,
top: 58,
bottom: 96,
child: _gimbalSlider(),
),
// Right camera controls
Positioned(
right: 16,
top: 0,
bottom: 0,
child: Center(child: _cameraControls()),
),
// Bottom-left minimap
Positioned(left: 14, bottom: 12, child: _minimap()), Positioned(left: 14, bottom: 12, child: _minimap()),
Positioned(bottom: 14, left: 0, right: 0, child: Center(child: _telemetry())),
// Bottom-center telemetry
Positioned(
bottom: 14,
left: 0,
right: 0,
child: Center(child: _telemetry(m)),
),
// RTH button
Positioned(right: 92, bottom: 20, child: _rthButton()), Positioned(right: 92, bottom: 20, child: _rthButton()),
Positioned(right: 92, bottom: 72, child: _takeoffButton()),
], ],
); );
}, },
@@ -136,54 +201,64 @@ class _FlightControlPageState extends State<FlightControlPage> {
} }
// ── Top bar ────────────────────────────────────────────────────────────── // ── Top bar ──────────────────────────────────────────────────────────────
Widget _topBar(FlightModel m) { Widget _topBar() {
return Row( final String mode = (_m.flightMode != null && _m.flightMode!.isNotEmpty) ? _m.flightMode! : 'N';
children: <Widget>[ return Row(children: <Widget>[
GestureDetector( GestureDetector(
onTap: () => Navigator.of(context).maybePop(), onTap: () => Navigator.of(context).maybePop(),
child: _pill(child: const Icon(Icons.chevron_left, size: 16, color: Glass.ink)), child: _pill(child: const PVIcon('chevronLeft', size: 16, color: Glass.ink)),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Container( Container(
height: 26, height: 26,
padding: const EdgeInsets.symmetric(horizontal: 11), padding: const EdgeInsets.symmetric(horizontal: 11),
decoration: BoxDecoration(color: Glass.accent, borderRadius: BorderRadius.circular(8)), decoration: BoxDecoration(color: Glass.accent, borderRadius: BorderRadius.circular(8)),
alignment: Alignment.center, alignment: Alignment.center,
child: Text( child: Text(mode, style: const TextStyle(fontFamily: PV.fontSans, fontSize: 12, fontWeight: FontWeight.w700, letterSpacing: 0.4, color: Colors.white)),
(m.flightMode != null && m.flightMode!.isNotEmpty) ? m.flightMode! : 'N', ),
style: const TextStyle(fontFamily: PV.fontSans, fontSize: 12, fontWeight: FontWeight.w700, letterSpacing: 0.4, color: Colors.white), const SizedBox(width: 8),
), _pill(child: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
), const PVIcon('satellite', size: 14, color: Glass.sat),
const SizedBox(width: 8), const SizedBox(width: 4),
_pill(child: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[ _mono(_m.satellites?.toString() ?? '0'),
const Icon(Icons.satellite_alt, size: 14, color: Glass.sat), ])),
const SizedBox(width: 4), const SizedBox(width: 8),
_mono(m.satellites?.toString() ?? '0'), _pill(child: const PVIcon('obstacle', size: 13, color: Glass.sat)),
])), const SizedBox(width: 8),
const SizedBox(width: 8), _pill(child: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
_pill(child: Row(mainAxisSize: MainAxisSize.min, children: const <Widget>[ const PVIcon('radio', size: 14, color: Glass.ink),
Icon(Icons.sensors, size: 14, color: Glass.ink), const SizedBox(width: 4),
SizedBox(width: 4), _mono('HD'),
Text('HD', style: TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: Glass.ink)), ])),
])), const Spacer(),
const Spacer(), _pill(child: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
_pill(child: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[ _mono('REC'),
_mono('REC'), const SizedBox(width: 6),
const SizedBox(width: 6), Container(width: 7, height: 7, decoration: BoxDecoration(color: _m.isRecording ? Glass.rec : const Color(0x66D64545), shape: BoxShape.circle)),
Container(width: 7, height: 7, decoration: const BoxDecoration(color: Glass.rec, shape: BoxShape.circle)), const SizedBox(width: 6),
const SizedBox(width: 6), _mono(_fmt(_m.isRecording ? _recSeconds : 0)),
_mono(_recLabel), ])),
])), const SizedBox(width: 8),
const SizedBox(width: 8), _pill(child: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
_pill(child: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[ const PVIcon('battery', size: 16, color: Glass.sat),
const Icon(Icons.battery_full, size: 16, color: Glass.sat), const SizedBox(width: 4),
const SizedBox(width: 4), _mono(_m.batteryPercent == null ? '' : '${_m.batteryPercent}%'),
_mono(m.batteryPercent == null ? '' : '${m.batteryPercent}%'), ])),
])), const SizedBox(width: 8),
const SizedBox(width: 8), GestureDetector(onTap: _openSettingsMenu, child: _pill(child: const PVIcon('more', size: 16, color: Glass.ink))),
_pill(child: const Icon(Icons.settings, size: 16, color: Glass.ink)), ]);
], }
);
Widget _leftRail() {
return Column(children: <Widget>[
_sideBtn('gimbal', active: true, onTap: _openCameraSettings),
const SizedBox(height: 10),
_sideBtn('sun', onTap: _openCameraSettings),
const SizedBox(height: 10),
_sideBtn('aperture', onTap: _openCaptureModes),
const SizedBox(height: 10),
_sideBtn('grid', active: _grid, onTap: () => setState(() => _grid = !_grid)),
]);
} }
// ── Reusable glass pieces ──────────────────────────────────────────────── // ── Reusable glass pieces ────────────────────────────────────────────────
@@ -199,167 +274,168 @@ class _FlightControlPageState extends State<FlightControlPage> {
Widget _mono(String t) => Text(t, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: Glass.ink)); Widget _mono(String t) => Text(t, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: Glass.ink));
Widget _sideBtn(IconData icon, {bool active = false}) { Widget _sideBtn(String icon, {bool active = false, VoidCallback? onTap}) {
return Container( return GestureDetector(
width: 42, onTap: onTap,
height: 42, child: Container(
decoration: BoxDecoration( width: 42, height: 42,
color: active ? Glass.accent : Glass.pill, decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12), color: active ? Glass.accent : Glass.pill,
border: Border.all(color: Glass.hairline), borderRadius: BorderRadius.circular(12),
border: Border.all(color: Glass.hairline),
),
alignment: Alignment.center,
child: PVIcon(icon, size: 20, stroke: 1.8, color: Glass.ink),
), ),
child: Icon(icon, size: 20, color: Glass.ink),
); );
} }
Widget _gimbalSlider() { Widget _gimbalSlider() {
return SizedBox( return LayoutBuilder(builder: (BuildContext context, BoxConstraints c) {
width: 16, final double h = c.maxHeight;
child: Stack( return GestureDetector(
alignment: Alignment.topCenter, behavior: HitTestBehavior.opaque,
children: <Widget>[ onVerticalDragUpdate: (DragUpdateDetails d) => _onGimbalDrag(d.localPosition.dy / h),
Container(width: 6, decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(6))), onTapDown: (TapDownDetails d) => _onGimbalDrag(d.localPosition.dy / h),
const Align( child: SizedBox(
alignment: Alignment(0, -0.24), width: 16,
child: _Thumb(), child: Stack(children: <Widget>[
), Align(
], alignment: Alignment.topCenter,
), child: Container(width: 6, height: h, decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(6))),
); ),
Positioned(
top: (_gimbalFrac * h - 8).clamp(0.0, h - 16),
left: 0,
child: const _Thumb(),
),
]),
),
);
});
} }
Widget _cameraControls() { Widget _cameraControls() {
return Column( return Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
mainAxisSize: MainAxisSize.min, GestureDetector(
children: <Widget>[ onTap: _openCameraSettings,
Container( child: Container(
width: 46, width: 46, height: 46,
height: 46,
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0x99FFFFFF), width: 2), border: Border.all(color: const Color(0x99FFFFFF), width: 2),
gradient: const LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: <Color>[Color(0xFF2A4E86), Color(0xFF12201A)]), gradient: const LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: <Color>[Color(0xFF2A4E86), Color(0xFF12201A)]),
), ),
), ),
const SizedBox(height: 14), ),
// Shutter const SizedBox(height: 14),
Container( GestureDetector(
width: 62, onTap: _toggleShutter,
height: 62, child: Container(
decoration: BoxDecoration( width: 62, height: 62,
shape: BoxShape.circle, decoration: BoxDecoration(shape: BoxShape.circle, border: Border.all(color: const Color(0xD9FFFFFF), width: 4)),
border: Border.all(color: const Color(0xD9FFFFFF), width: 4),
),
child: Center( child: Center(
child: Container( child: _m.captureMode == CaptureMode.video && _m.isRecording
width: 26, ? Container(width: 24, height: 24, decoration: BoxDecoration(color: Glass.rec, borderRadius: BorderRadius.circular(5)))
height: 26, : Container(width: 46, height: 46, decoration: BoxDecoration(color: _m.captureMode == CaptureMode.video ? Glass.rec : Colors.white, shape: BoxShape.circle)),
decoration: BoxDecoration(color: Glass.rec, borderRadius: BorderRadius.circular(7)),
),
), ),
), ),
const SizedBox(height: 14), ),
// Mode switch const SizedBox(height: 14),
Container( Container(
padding: const EdgeInsets.all(3), padding: const EdgeInsets.all(3),
decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(10)), decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(10)),
child: Column( child: Column(children: <Widget>[
children: <Widget>[ _modeBtn('image', CaptureMode.photo),
_modeBtn(Icons.photo_outlined, 'Photo'), _modeBtn('video', CaptureMode.video),
_modeBtn(Icons.videocam_outlined, 'Video'), _modeBtn('film', CaptureMode.pano),
_modeBtn(Icons.panorama_outlined, 'Pano'), ]),
], ),
), ]);
),
],
);
} }
Widget _modeBtn(IconData icon, String mode) { Widget _modeBtn(String icon, CaptureMode mode) {
final bool active = _mode == mode; final bool active = _m.captureMode == mode;
return GestureDetector( return GestureDetector(
onTap: () => setState(() => _mode = mode), onTap: () => _setCaptureMode(mode),
child: Container( child: Container(
width: 40, width: 40, height: 30,
height: 30,
margin: const EdgeInsets.symmetric(vertical: 1), margin: const EdgeInsets.symmetric(vertical: 1),
decoration: BoxDecoration( decoration: BoxDecoration(color: active ? Glass.accent : Colors.transparent, borderRadius: BorderRadius.circular(7)),
color: active ? Glass.accent : Colors.transparent, alignment: Alignment.center,
borderRadius: BorderRadius.circular(7), child: PVIcon(icon, size: 17, stroke: 1.8, color: Glass.ink),
),
child: Icon(icon, size: 17, color: Glass.ink),
), ),
); );
} }
Widget _minimap() { Widget _minimap() {
return Container( final String rth = _m.homeDistance == null ? 'RTH —' : 'RTH ${_m.homeDistance!.toStringAsFixed(0)}m';
width: 148, return GestureDetector(
height: 78, onTap: _openMap,
decoration: BoxDecoration( child: Container(
color: Glass.pillStrong, width: 148, height: 78,
borderRadius: BorderRadius.circular(12), decoration: BoxDecoration(color: Glass.pillStrong, borderRadius: BorderRadius.circular(12), border: Border.all(color: Glass.hairline)),
border: Border.all(color: Glass.hairline), child: Stack(children: <Widget>[
),
child: Stack(
children: <Widget>[
const Positioned.fill(child: CustomPaint(painter: _MinimapPainter())), const Positioned.fill(child: CustomPaint(painter: _MinimapPainter())),
const Positioned( Positioned(top: 6, left: 8, child: Row(children: <Widget>[
top: 6, const PVIcon('home', size: 12, color: Glass.ink),
left: 8, const SizedBox(width: 5),
child: Row(children: <Widget>[ Text(rth, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 10, color: Glass.ink)),
Icon(Icons.home_outlined, size: 12, color: Glass.ink), ])),
SizedBox(width: 5), ]),
Text('RTH 340m', style: TextStyle(fontFamily: PV.fontMono, fontSize: 10, color: Glass.ink)),
]),
),
],
), ),
); );
} }
Widget _telemetry(FlightModel m) { Widget _telemetry() {
String f(double? v, {int d = 1}) => v == null ? '' : v.toStringAsFixed(d);
final List<(String, String, String)> fields = <(String, String, String)>[ final List<(String, String, String)> fields = <(String, String, String)>[
('H', m.altitude == null ? '' : m.altitude!.toStringAsFixed(1), 'm'), ('H', f(_m.altitude), 'm'),
('D', '', 'm'), ('D', f(_m.homeDistance, d: 0), 'm'),
('H.S', '', 'm/s'), ('H.S', f(_m.horizontalSpeed), 'm/s'),
('V.S', '', 'm/s'), ('V.S', f(_m.verticalSpeed), 'm/s'),
]; ];
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 8),
decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(12)), decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(12)),
child: Row( child: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
mainAxisSize: MainAxisSize.min, for (int i = 0; i < fields.length; i++) ...<Widget>[
children: <Widget>[ if (i > 0) const SizedBox(width: 20),
for (int i = 0; i < fields.length; i++) ...<Widget>[ Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
if (i > 0) const SizedBox(width: 20), Text(fields[i].$1, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 10, letterSpacing: 0.8, color: Color(0x99EAF0FA))),
Column( const SizedBox(height: 2),
mainAxisSize: MainAxisSize.min, Text.rich(TextSpan(children: <TextSpan>[
children: <Widget>[ TextSpan(text: fields[i].$2, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 15, fontWeight: FontWeight.w700, color: Glass.ink)),
Text(fields[i].$1, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 10, letterSpacing: 0.8, color: Color(0x99EAF0FA))), TextSpan(text: ' ${fields[i].$3}', style: const TextStyle(fontFamily: PV.fontMono, fontSize: 10, color: Color(0x99EAF0FA))),
const SizedBox(height: 2), ])),
Text.rich(TextSpan(children: <TextSpan>[ ]),
TextSpan(text: fields[i].$2, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 15, fontWeight: FontWeight.w700, color: Glass.ink)),
TextSpan(text: ' ${fields[i].$3}', style: const TextStyle(fontFamily: PV.fontMono, fontSize: 10, color: Color(0x99EAF0FA))),
])),
],
),
],
], ],
), ]),
); );
} }
Widget _rthButton() { Widget _rthButton() {
return Container( return GestureDetector(
width: 44, onTap: _rth,
height: 44, child: Container(
decoration: BoxDecoration( width: 44, height: 44,
color: Glass.pillStrong, decoration: BoxDecoration(color: Glass.pillStrong, shape: BoxShape.circle, border: Border.all(color: const Color(0x2EFFFFFF))),
shape: BoxShape.circle, alignment: Alignment.center,
border: Border.all(color: const Color(0x2EFFFFFF)), child: const PVIcon('rth', size: 20, color: Glass.ink),
),
);
}
Widget _takeoffButton() {
final bool flying = _m.isFlying == true;
return GestureDetector(
onTap: _toggleTakeoff,
child: Container(
width: 44, height: 44,
decoration: BoxDecoration(color: flying ? const Color(0xE6D64545) : Glass.accent, shape: BoxShape.circle, border: Border.all(color: const Color(0x2EFFFFFF))),
alignment: Alignment.center,
child: PVIcon(flying ? 'home' : 'takeoff', size: 20, color: Colors.white),
), ),
child: const Icon(Icons.home_outlined, size: 20, color: Glass.ink),
); );
} }
} }
@@ -369,17 +445,34 @@ class _Thumb extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
width: 16, width: 16, height: 16,
height: 16,
decoration: const BoxDecoration( decoration: const BoxDecoration(
color: Glass.ink, color: Glass.ink, shape: BoxShape.circle,
shape: BoxShape.circle,
boxShadow: <BoxShadow>[BoxShadow(color: Color(0x80000000), blurRadius: 3, offset: Offset(0, 1))], boxShadow: <BoxShadow>[BoxShadow(color: Color(0x80000000), blurRadius: 3, offset: Offset(0, 1))],
), ),
); );
} }
} }
/// Rule-of-thirds grid overlay toggled from the left rail.
class _GridPainter extends CustomPainter {
const _GridPainter();
@override
void paint(Canvas canvas, Size size) {
final Paint p = Paint()
..color = const Color(0x33FFFFFF)
..strokeWidth = 1;
for (int i = 1; i < 3; i++) {
final double x = size.width * i / 3, y = size.height * i / 3;
canvas.drawLine(Offset(x, 0), Offset(x, size.height), p);
canvas.drawLine(Offset(0, y), Offset(size.width, y), p);
}
}
@override
bool shouldRepaint(covariant _GridPainter oldDelegate) => false;
}
/// Painted placeholder camera feed: graded sky→ground, perspective grid, haze, /// Painted placeholder camera feed: graded sky→ground, perspective grid, haze,
/// distant buildings, and a yellow tracked-subject bracket. /// distant buildings, and a yellow tracked-subject bracket.
class _FeedPainter extends CustomPainter { class _FeedPainter extends CustomPainter {
@@ -390,7 +483,6 @@ class _FeedPainter extends CustomPainter {
final double w = size.width, h = size.height; final double w = size.width, h = size.height;
final double horizon = h * 0.52; final double horizon = h * 0.52;
// Sky → ground gradient.
final Rect full = Offset.zero & size; final Rect full = Offset.zero & size;
final Paint sky = Paint() final Paint sky = Paint()
..shader = const LinearGradient( ..shader = const LinearGradient(
@@ -401,7 +493,6 @@ class _FeedPainter extends CustomPainter {
).createShader(full); ).createShader(full);
canvas.drawRect(full, sky); canvas.drawRect(full, sky);
// Horizon haze.
final Paint haze = Paint() final Paint haze = Paint()
..shader = LinearGradient( ..shader = LinearGradient(
begin: Alignment.topCenter, begin: Alignment.topCenter,
@@ -410,7 +501,6 @@ class _FeedPainter extends CustomPainter {
).createShader(Rect.fromLTWH(0, horizon - 30, w, 60)); ).createShader(Rect.fromLTWH(0, horizon - 30, w, 60));
canvas.drawRect(Rect.fromLTWH(0, horizon - 30, w, 60), haze); canvas.drawRect(Rect.fromLTWH(0, horizon - 30, w, 60), haze);
// Distant buildings just under the horizon.
final Paint bld = Paint()..color = const Color(0xE612201A); final Paint bld = Paint()..color = const Color(0xE612201A);
void building(double x, double y, double bw, double bh) => canvas.drawRect(Rect.fromLTWH(x * w, horizon + y, bw, bh), bld); void building(double x, double y, double bw, double bh) => canvas.drawRect(Rect.fromLTWH(x * w, horizon + y, bw, bh), bld);
building(0.10, -40, 46, 40); building(0.10, -40, 46, 40);
@@ -418,7 +508,6 @@ class _FeedPainter extends CustomPainter {
building(0.74, -46, 54, 46); building(0.74, -46, 54, 46);
building(0.83, -34, 34, 34); building(0.83, -34, 34, 34);
// Perspective ground grid.
final Paint grid = Paint() final Paint grid = Paint()
..color = const Color(0x297FE0B0) ..color = const Color(0x297FE0B0)
..strokeWidth = 1; ..strokeWidth = 1;
@@ -433,15 +522,13 @@ class _FeedPainter extends CustomPainter {
canvas.drawLine(Offset(vx + k * 10, horizon), Offset(bx, h), grid); canvas.drawLine(Offset(vx + k * 10, horizon), Offset(bx, h), grid);
} }
// Tracked-subject bracket, centered.
final Paint subj = Paint() final Paint subj = Paint()
..color = Glass.subject ..color = Glass.subject
..style = PaintingStyle.stroke ..style = PaintingStyle.stroke
..strokeWidth = 2; ..strokeWidth = 2;
final double bxw = 118, bxh = 82; const double bxw = 118, bxh = 82;
final Rect box = Rect.fromCenter(center: Offset(vx, horizon + 8), width: bxw, height: bxh); final Rect box = Rect.fromCenter(center: Offset(vx, horizon + 8), width: bxw, height: bxh);
const double c = 14; const double c = 14;
// Four corner brackets.
canvas.drawPath(Path()..moveTo(box.left + c, box.top)..lineTo(box.left, box.top)..lineTo(box.left, box.top + c), subj); canvas.drawPath(Path()..moveTo(box.left + c, box.top)..lineTo(box.left, box.top)..lineTo(box.left, box.top + c), subj);
canvas.drawPath(Path()..moveTo(box.right - c, box.top)..lineTo(box.right, box.top)..lineTo(box.right, box.top + c), subj); canvas.drawPath(Path()..moveTo(box.right - c, box.top)..lineTo(box.right, box.top)..lineTo(box.right, box.top + c), subj);
canvas.drawPath(Path()..moveTo(box.left + c, box.bottom)..lineTo(box.left, box.bottom)..lineTo(box.left, box.bottom - c), subj); canvas.drawPath(Path()..moveTo(box.left + c, box.bottom)..lineTo(box.left, box.bottom)..lineTo(box.left, box.bottom - c), subj);
+16
View File
@@ -0,0 +1,16 @@
import 'package:flutter/material.dart';
import 'simple_list_page.dart';
/// Flight-log history. Per-session logging/persistence is a later add, so this
/// shows the empty state for now.
class FlightLogsPage extends StatelessWidget {
const FlightLogsPage({super.key});
@override
Widget build(BuildContext context) => const SimpleListPage(
title: 'Flight logs',
emptyIcon: 'gauge',
emptyText: 'No flights recorded yet.\nYour flight history will appear here.',
);
}
@@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../theme.dart';
import 'pv_icons.dart';
/// Shared chrome for the landscape flight overlays (Capture modes, Camera
/// settings, Settings menu). Locks landscape/immersive like the HUD, paints the
/// dark app ground, and provides a title row + close button.
class FlightOverlayScaffold extends StatefulWidget {
const FlightOverlayScaffold({
super.key,
required this.title,
required this.body,
this.leading,
this.trailing,
this.padded = true,
});
final String title;
final Widget body;
final Widget? leading;
final Widget? trailing;
final bool padded;
@override
State<FlightOverlayScaffold> createState() => _FlightOverlayScaffoldState();
}
class _FlightOverlayScaffoldState extends State<FlightOverlayScaffold> {
@override
void initState() {
super.initState();
SystemChrome.setPreferredOrientations(<DeviceOrientation>[
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF0A1120),
body: SafeArea(
child: Padding(
padding: widget.padded ? const EdgeInsets.fromLTRB(16, 12, 16, 12) : EdgeInsets.zero,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Padding(
padding: widget.padded ? EdgeInsets.zero : const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Row(children: <Widget>[
if (widget.leading != null) ...<Widget>[widget.leading!, const SizedBox(width: 10)],
Text(widget.title,
style: const TextStyle(fontFamily: PV.fontSans, fontSize: 16, fontWeight: FontWeight.w700, color: Glass.ink)),
const Spacer(),
if (widget.trailing != null) ...<Widget>[widget.trailing!, const SizedBox(width: 8)],
GestureDetector(
onTap: () => Navigator.of(context).maybePop(),
child: Container(
width: 30, height: 30,
decoration: BoxDecoration(color: const Color(0x1FFFFFFF), borderRadius: BorderRadius.circular(9)),
alignment: Alignment.center,
child: const PVIcon('close', size: 17, color: Glass.ink),
),
),
]),
),
const SizedBox(height: 10),
Expanded(child: widget.body),
],
),
),
),
);
}
}
-292
View File
@@ -1,292 +0,0 @@
import 'package:flutter/material.dart';
import '../flight_model.dart';
import '../theme.dart';
import '../uploader.dart';
/// Portrait launch screen — mirrors the ui_kit/fly "Go Fly · launch" mockup:
/// brand header, aircraft connection card, big GO FLY, and a 2×2 tile grid.
class GoFlyPage extends StatelessWidget {
const GoFlyPage({
super.key,
required this.model,
required this.onGoFly,
required this.onOpenAlbum,
required this.onSettings,
required this.onTile,
});
final FlightModel model;
final VoidCallback onGoFly;
final VoidCallback onOpenAlbum;
final VoidCallback onSettings;
final void Function(String tile) onTile;
@override
Widget build(BuildContext context) {
final PVScheme s = PVScheme.of(context);
return Scaffold(
backgroundColor: s.bgApp,
body: SafeArea(
child: AnimatedBuilder(
animation: model,
builder: (BuildContext context, _) {
return Column(
children: <Widget>[
_header(s),
Expanded(
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: _connectionCard(s),
),
),
),
Padding(
// No bottom gap here: the design pins GO FLY directly above the
// tiles' 18px top pad, which centers the card 4px lower to match.
padding: const EdgeInsets.fromLTRB(20, 0, 20, 0),
child: _goFlyButton(s),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 28),
child: _tiles(s),
),
],
);
},
),
),
);
}
Widget _header(PVScheme s) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 10, 20, 0),
child: Row(
children: <Widget>[
const PvBrandMark(size: 22),
const SizedBox(width: 8),
Text.rich(
TextSpan(children: <TextSpan>[
TextSpan(
text: 'Pilot',
style: TextStyle(fontFamily: PV.fontSans, fontSize: 19, fontWeight: FontWeight.w500, letterSpacing: -0.38, color: s.textSecondary),
),
TextSpan(
text: 'Vault',
style: TextStyle(fontFamily: PV.fontSans, fontSize: 19, fontWeight: FontWeight.w700, letterSpacing: -0.38, color: s.textPrimary),
),
TextSpan(
text: ' Fly',
style: TextStyle(fontFamily: PV.fontSans, fontSize: 19, fontWeight: FontWeight.w500, letterSpacing: -0.38, color: s.accent),
),
]),
),
const Spacer(),
GestureDetector(
onTap: onSettings,
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(color: s.surfaceInset, shape: BoxShape.circle),
child: Icon(Icons.person_outline, size: 17, color: s.textSecondary),
),
),
],
),
);
}
Widget _connectionCard(PVScheme s) {
final bool connected = model.connected;
final Color dot = connected ? s.success : s.textTertiary;
final Color statusFg = connected ? s.successFg : s.textTertiary;
final String statusText = connected ? 'CONNECTED' : 'DISCONNECTED';
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: s.surface,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: s.border),
boxShadow: s.shadowSm,
),
child: Column(
mainAxisSize: MainAxisSize.min, // size to content; Center handles vertical placement
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Container(width: 7, height: 7, decoration: BoxDecoration(color: dot, shape: BoxShape.circle)),
const SizedBox(width: 6),
Text(
statusText,
style: TextStyle(fontFamily: PV.fontMono, fontSize: 11, letterSpacing: 1.1, fontWeight: FontWeight.w700, color: statusFg),
),
],
),
const SizedBox(height: 14),
Row(
children: <Widget>[
Container(
width: 56,
height: 56,
decoration: BoxDecoration(color: s.accentSoft, borderRadius: BorderRadius.circular(14)),
child: Icon(Icons.flight, size: 28, color: s.accent),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
connected ? (model.model ?? 'Aircraft') : 'No aircraft',
style: TextStyle(fontFamily: PV.fontSans, fontSize: 18, fontWeight: FontWeight.w700, color: s.textPrimary),
),
const SizedBox(height: 2),
Text(
'MSDK · ${model.sdkVersion}',
style: TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: s.textSecondary),
),
],
),
),
],
),
const SizedBox(height: 14),
Row(
children: <Widget>[
_chip(s, Icons.battery_full, model.batteryPercent == null ? '' : '${model.batteryPercent}%'),
const SizedBox(width: 8),
_chip(s, Icons.satellite_alt, model.satellites == null ? '— sats' : '${model.satellites} sats'),
const SizedBox(width: 8),
_chip(s, Icons.link, _linkLabel(model.upload, connected)),
],
),
],
),
);
}
String _linkLabel(UploadStatus u, bool connected) {
switch (u) {
case UploadStatus.connected:
return 'Streaming';
case UploadStatus.connecting:
return 'Linking…';
case UploadStatus.error:
return 'Retrying';
case UploadStatus.disabled:
return connected ? 'Linked' : 'Off';
}
}
Widget _chip(PVScheme s, IconData icon, String value) {
return Expanded(
child: Container(
height: 34,
decoration: BoxDecoration(color: s.surfaceInset, borderRadius: BorderRadius.circular(9)),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Icon(icon, size: 14, color: s.textTertiary),
const SizedBox(width: 6),
Flexible(
child: Text(
value,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontFamily: PV.fontMono, fontSize: 11.5, color: s.textSecondary),
),
),
],
),
),
);
}
Widget _goFlyButton(PVScheme s) {
return SizedBox(
height: 58,
child: FilledButton(
onPressed: onGoFly,
style: FilledButton.styleFrom(
backgroundColor: s.accent,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: const <Widget>[
Icon(Icons.play_arrow_rounded, size: 22),
SizedBox(width: 10),
Text('GO FLY', style: TextStyle(fontFamily: PV.fontSans, fontSize: 18, fontWeight: FontWeight.w700, letterSpacing: 0.4)),
],
),
),
);
}
Widget _tiles(PVScheme s) {
const List<(IconData, String)> tiles = <(IconData, String)>[
(Icons.photo_library_outlined, 'Album'),
(Icons.school_outlined, 'Academy'),
(Icons.route_outlined, 'Routes'),
(Icons.speed, 'Flight logs'),
];
return Column(
children: <Widget>[
Row(children: <Widget>[
Expanded(child: _tile(s, tiles[0])),
const SizedBox(width: 12),
Expanded(child: _tile(s, tiles[1])),
]),
const SizedBox(height: 12),
Row(children: <Widget>[
Expanded(child: _tile(s, tiles[2])),
const SizedBox(width: 12),
Expanded(child: _tile(s, tiles[3])),
]),
],
);
}
Widget _tile(PVScheme s, (IconData, String) t) {
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: () => t.$2 == 'Album' ? onOpenAlbum() : onTile(t.$2),
child: Container(
height: 56,
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(
color: s.surface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: s.border),
boxShadow: s.shadowXs,
),
child: Row(
children: <Widget>[
Container(
width: 32,
height: 32,
decoration: BoxDecoration(color: s.accentSoft, borderRadius: BorderRadius.circular(9)),
child: Icon(t.$1, size: 17, color: s.accentSoftFg),
),
const SizedBox(width: 10),
Flexible(
child: Text(
t.$2,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontFamily: PV.fontSans, fontSize: 14, fontWeight: FontWeight.w600, color: s.textPrimary),
),
),
],
),
),
),
);
}
}
+285
View File
@@ -0,0 +1,285 @@
import 'package:flutter/material.dart';
import '../flight_model.dart';
import '../theme.dart';
import '../uploader.dart';
import 'pv_icons.dart';
/// Portrait launch screen — mirrors the v2 "Home" mockups (connected /
/// disconnected). Brand header + avatar, aircraft card, GO FLY, and a 2×2 tile
/// grid (Album / Academy / Routes / Flight logs).
class HomeScreen extends StatelessWidget {
const HomeScreen({
super.key,
required this.model,
required this.onGoFly,
required this.onConnect,
required this.onSettings,
required this.onTile,
});
final FlightModel model;
final VoidCallback onGoFly;
final VoidCallback onConnect;
final VoidCallback onSettings;
final void Function(String tile) onTile;
@override
Widget build(BuildContext context) {
final PVScheme s = PVScheme.of(context);
return Scaffold(
backgroundColor: s.bgApp,
body: SafeArea(
child: AnimatedBuilder(
animation: model,
builder: (BuildContext context, _) {
final bool connected = model.connected;
return Column(
children: <Widget>[
_brandRow(s),
Expanded(
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: connected ? _connectedCard(s) : _disconnectedCard(s),
),
),
),
if (connected)
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 0),
child: _goFly(s),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 28),
child: _tiles(s),
),
],
);
},
),
),
);
}
Widget _brandRow(PVScheme s) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 10, 20, 0),
child: Row(
children: <Widget>[
const PvBrandMark(size: 22),
const SizedBox(width: 8),
Text.rich(TextSpan(children: <TextSpan>[
TextSpan(text: 'Pilot', style: _brand(s.textSecondary, FontWeight.w500)),
TextSpan(text: 'Vault', style: _brand(s.textPrimary, FontWeight.w700)),
TextSpan(text: ' Fly', style: _brand(s.accent, FontWeight.w500)),
])),
const Spacer(),
GestureDetector(
onTap: onSettings,
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(color: s.surfaceInset, shape: BoxShape.circle),
alignment: Alignment.center,
child: PVIcon('user', size: 17, color: s.textSecondary),
),
),
],
),
);
}
TextStyle _brand(Color c, FontWeight w) => TextStyle(
fontFamily: PV.fontSans, fontSize: 19, fontWeight: w, letterSpacing: -0.38, color: c);
Widget _connectedCard(PVScheme s) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: s.surface,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: s.border),
boxShadow: s.shadowSm,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(children: <Widget>[
Container(width: 7, height: 7, decoration: BoxDecoration(color: s.success, shape: BoxShape.circle)),
const SizedBox(width: 6),
Text('CONNECTED',
style: TextStyle(fontFamily: PV.fontMono, fontSize: 11, letterSpacing: 1.1, fontWeight: FontWeight.w700, color: s.successFg)),
]),
const SizedBox(height: 14),
Row(children: <Widget>[
Container(
width: 56, height: 56,
decoration: BoxDecoration(color: s.accentSoft, borderRadius: BorderRadius.circular(14)),
alignment: Alignment.center,
child: PVIcon('drone', size: 30, stroke: 1.6, color: s.accent),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(model.model ?? 'Aircraft',
style: TextStyle(fontFamily: PV.fontSans, fontSize: 18, fontWeight: FontWeight.w700, color: s.textPrimary)),
const SizedBox(height: 2),
Text(model.firmwareVersion == null ? 'MSDK · ${model.sdkVersion}' : 'FW ${model.firmwareVersion}',
style: TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: s.textSecondary)),
],
),
),
]),
const SizedBox(height: 14),
Row(children: <Widget>[
_chip(s, 'battery', model.batteryPercent == null ? '' : '${model.batteryPercent}%'),
const SizedBox(width: 8),
_chip(s, 'sdcard', model.sdRemainingMB == null ? '— GB' : '${(model.sdRemainingMB! / 1024).toStringAsFixed(0)} GB'),
const SizedBox(width: 8),
_chip(s, 'link', _linkLabel(model.upload)),
]),
],
),
);
}
Widget _disconnectedCard(PVScheme s) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: s.surface,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: s.borderStrong, style: BorderStyle.solid),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Container(
width: 64, height: 64,
decoration: BoxDecoration(color: s.surfaceInset, borderRadius: BorderRadius.circular(18)),
alignment: Alignment.center,
child: PVIcon('drone', size: 34, stroke: 1.5, color: s.textTertiary),
),
const SizedBox(height: 14),
Text('No aircraft connected',
style: TextStyle(fontFamily: PV.fontSans, fontSize: 17, fontWeight: FontWeight.w700, color: s.textPrimary)),
const SizedBox(height: 4),
Text('Power on your aircraft and remote controller, then connect to begin.',
textAlign: TextAlign.center,
style: TextStyle(fontFamily: PV.fontSans, fontSize: 13, height: 1.5, color: s.textSecondary)),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
height: 50,
child: FilledButton(
onPressed: onConnect,
style: FilledButton.styleFrom(
backgroundColor: s.accent, foregroundColor: Colors.white, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[
const PVIcon('link', size: 18, color: Colors.white),
const SizedBox(width: 8),
const Text('Connect aircraft', style: TextStyle(fontFamily: PV.fontSans, fontSize: 15, fontWeight: FontWeight.w700)),
]),
),
),
],
),
);
}
String _linkLabel(UploadStatus u) {
switch (u) {
case UploadStatus.connected:
return 'Streaming';
case UploadStatus.connecting:
return 'Linking…';
case UploadStatus.error:
return 'Retrying';
case UploadStatus.disabled:
return 'RC linked';
}
}
Widget _chip(PVScheme s, String icon, String value) {
return Expanded(
child: Container(
height: 34,
decoration: BoxDecoration(color: s.surfaceInset, borderRadius: BorderRadius.circular(9)),
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[
PVIcon(icon, size: 14, color: s.textTertiary),
const SizedBox(width: 6),
Flexible(child: Text(value, overflow: TextOverflow.ellipsis,
style: TextStyle(fontFamily: PV.fontMono, fontSize: 11.5, color: s.textSecondary))),
]),
),
);
}
Widget _goFly(PVScheme s) {
return SizedBox(
height: 58,
child: FilledButton(
onPressed: onGoFly,
style: FilledButton.styleFrom(
backgroundColor: s.accent, foregroundColor: Colors.white, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: const <Widget>[
PVIcon('play2', size: 20, color: Colors.white, fill: true),
SizedBox(width: 10),
Text('GO FLY', style: TextStyle(fontFamily: PV.fontSans, fontSize: 18, fontWeight: FontWeight.w700, letterSpacing: 0.4)),
]),
),
);
}
Widget _tiles(PVScheme s) {
const List<(String, String)> tiles = <(String, String)>[
('album', 'Album'), ('academy', 'Academy'), ('route', 'Routes'), ('gauge', 'Flight logs'),
];
return Column(children: <Widget>[
Row(children: <Widget>[
Expanded(child: _tile(s, tiles[0])), const SizedBox(width: 12), Expanded(child: _tile(s, tiles[1])),
]),
const SizedBox(height: 12),
Row(children: <Widget>[
Expanded(child: _tile(s, tiles[2])), const SizedBox(width: 12), Expanded(child: _tile(s, tiles[3])),
]),
]);
}
Widget _tile(PVScheme s, (String, String) t) {
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: () => onTile(t.$2),
child: Container(
height: 56,
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(
color: s.surface, borderRadius: BorderRadius.circular(14),
border: Border.all(color: s.border), boxShadow: s.shadowXs,
),
child: Row(children: <Widget>[
Container(
width: 32, height: 32,
decoration: BoxDecoration(color: s.accentSoft, borderRadius: BorderRadius.circular(9)),
alignment: Alignment.center,
child: PVIcon(t.$1, size: 17, color: s.accentSoftFg),
),
const SizedBox(width: 10),
Flexible(child: Text(t.$2, overflow: TextOverflow.ellipsis,
style: TextStyle(fontFamily: PV.fontSans, fontSize: 14, fontWeight: FontWeight.w600, color: s.textPrimary))),
]),
),
),
);
}
}
+236
View File
@@ -0,0 +1,236 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import '../dji_service.dart';
import '../flight_model.dart';
import '../theme.dart';
import 'pv_icons.dart';
/// Map & waypoints — mirrors the v2 "Map & waypoints" mockup. Real OpenStreetMap
/// tiles (no API key); tapping the map in Pin mode drops real-GPS waypoints, and
/// "Run route" uploads + starts a Waypoint mission via [DjiService].
class MapPage extends StatefulWidget {
const MapPage({super.key, required this.model, required this.dji});
final FlightModel model;
final DjiService dji;
@override
State<MapPage> createState() => _MapPageState();
}
class _MapPageState extends State<MapPage> {
final MapController _map = MapController();
final List<LatLng> _waypoints = <LatLng>[];
bool _pinMode = true;
double _altitude = 50;
double _speed = 8;
DjiService get _dji => widget.dji;
FlightModel get _m => widget.model;
LatLng get _fallback => const LatLng(37.7749, -122.4194);
LatLng? get _drone => (_m.latitude != null && _m.longitude != null) ? LatLng(_m.latitude!, _m.longitude!) : null;
LatLng? get _home => (_m.homeLatitude != null && _m.homeLongitude != null) ? LatLng(_m.homeLatitude!, _m.homeLongitude!) : null;
@override
void initState() {
super.initState();
SystemChrome.setPreferredOrientations(<DeviceOrientation>[DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight]);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
}
void _snack(String msg) {
ScaffoldMessenger.of(context)
..clearSnackBars()
..showSnackBar(SnackBar(content: Text(msg)));
}
void _onTap(TapPosition pos, LatLng latlng) {
if (!_pinMode) return;
setState(() => _waypoints.add(latlng));
}
Future<void> _runRoute() async {
if (_waypoints.length < 2) {
_snack('Drop at least 2 waypoints first');
return;
}
final List<Map<String, dynamic>> points = _waypoints
.map((LatLng p) => <String, dynamic>{'lat': p.latitude, 'lon': p.longitude, 'altitude': _altitude})
.toList();
try {
await _dji.uploadWaypointMission(points, speed: _speed, finishAction: 'GO_HOME');
await _dji.startWaypointMission();
if (mounted) _snack('Route running — ${points.length} waypoints');
} catch (e) {
if (mounted) _snack('Route: ${e is PlatformException ? (e.message ?? e.code) : e}');
}
}
@override
Widget build(BuildContext context) {
final LatLng center = _drone ?? _home ?? _fallback;
return Scaffold(
backgroundColor: const Color(0xFF0E1726),
body: Stack(children: <Widget>[
FlutterMap(
mapController: _map,
options: MapOptions(initialCenter: center, initialZoom: 16, onTap: _onTap),
children: <Widget>[
TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.dji.flutter.dji_msdk_sample',
),
if (_waypoints.length >= 2)
PolylineLayer<Object>(polylines: <Polyline<Object>>[
Polyline<Object>(points: _waypoints, strokeWidth: 3, color: const Color(0xFF5B93F5)),
]),
MarkerLayer(markers: _markers()),
],
),
SafeArea(
child: Stack(children: <Widget>[
Positioned(top: 12, left: 14, child: _back()),
Positioned(left: 14, top: 58, child: _toolRail()),
Positioned(right: 16, top: 58, child: _routePanel()),
]),
),
]),
);
}
List<Marker> _markers() {
final List<Marker> m = <Marker>[];
for (int i = 0; i < _waypoints.length; i++) {
m.add(Marker(
point: _waypoints[i],
width: 26, height: 26,
child: Container(
decoration: const BoxDecoration(color: Color(0xFF3D7BF0), shape: BoxShape.circle),
alignment: Alignment.center,
child: Text('${i + 1}', style: const TextStyle(fontFamily: PV.fontMono, fontSize: 12, fontWeight: FontWeight.w700, color: Colors.white)),
),
));
}
if (_home != null) {
m.add(Marker(point: _home!, width: 24, height: 24, child: const _Dot(Color(0xFF7FE0B0), 'home')));
}
if (_drone != null) {
m.add(Marker(point: _drone!, width: 24, height: 24, child: const _Dot(Color(0xFFF4C542), 'drone')));
}
return m;
}
Widget _back() {
return GestureDetector(
onTap: () => Navigator.of(context).maybePop(),
child: Container(
height: 30, width: 30,
decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(8)),
alignment: Alignment.center,
child: const PVIcon('chevronLeft', size: 18, color: Glass.ink),
),
);
}
Widget _toolRail() {
return Column(children: <Widget>[
_tool('pin', _pinMode, () => setState(() => _pinMode = true)),
const SizedBox(height: 10),
_tool('route', false, () => setState(_waypoints.clear)),
const SizedBox(height: 10),
_tool('home', false, () { if (_home != null) _map.move(_home!, 16); }),
const SizedBox(height: 10),
_tool('crosshair', false, () { if (_drone != null) _map.move(_drone!, 16); }),
]);
}
Widget _tool(String icon, bool active, VoidCallback onTap) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 42, height: 42,
decoration: BoxDecoration(color: active ? Glass.accent : Glass.pill, borderRadius: BorderRadius.circular(12), border: Border.all(color: Glass.hairline)),
alignment: Alignment.center,
child: PVIcon(icon, size: 20, stroke: 1.8, color: Glass.ink),
),
);
}
Widget _routePanel() {
return Container(
width: 200,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(color: Glass.pillStrong, borderRadius: BorderRadius.circular(14), border: Border.all(color: Glass.hairline)),
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: <Widget>[
const Text('Waypoint route', style: TextStyle(fontFamily: PV.fontSans, fontSize: 13, fontWeight: FontWeight.w700, color: Glass.ink)),
const SizedBox(height: 8),
if (_waypoints.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: Text('Tap the map to drop waypoints', style: TextStyle(fontFamily: PV.fontMono, fontSize: 10.5, color: Color(0x99EAF0FA))),
)
else
...List<Widget>.generate(_waypoints.length, (int i) => Padding(
padding: const EdgeInsets.symmetric(vertical: 5),
child: Row(children: <Widget>[
Container(
width: 22, height: 22,
decoration: const BoxDecoration(color: Color(0xE63D7BF0), shape: BoxShape.circle),
alignment: Alignment.center,
child: Text('${i + 1}', style: const TextStyle(fontFamily: PV.fontMono, fontSize: 11, fontWeight: FontWeight.w700, color: Colors.white)),
),
const SizedBox(width: 9),
Text('Alt ${_altitude.toInt()}m · ${_speed.toInt()} m/s', style: const TextStyle(fontFamily: PV.fontMono, fontSize: 10.5, color: Color(0xCCEAF0FA))),
]),
)),
const SizedBox(height: 8),
_slider('Alt', _altitude, 20, 120, (double v) => setState(() => _altitude = v)),
_slider('Speed', _speed, 2, 15, (double v) => setState(() => _speed = v)),
const SizedBox(height: 8),
SizedBox(
height: 36,
child: FilledButton(
onPressed: _runRoute,
style: FilledButton.styleFrom(backgroundColor: const Color(0xFF3D7BF0), foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10))),
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: const <Widget>[
PVIcon('play2', size: 15, color: Colors.white, fill: true),
SizedBox(width: 6),
Text('Run route', style: TextStyle(fontFamily: PV.fontSans, fontSize: 13, fontWeight: FontWeight.w700)),
]),
),
),
]),
);
}
Widget _slider(String label, double value, double min, double max, ValueChanged<double> onChanged) {
return Row(children: <Widget>[
SizedBox(width: 38, child: Text(label, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 10, color: Color(0x99EAF0FA)))),
Expanded(
child: SliderTheme(
data: SliderThemeData(trackHeight: 2, thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6)),
child: Slider(value: value, min: min, max: max, activeColor: const Color(0xFF5B93F5), inactiveColor: const Color(0x33FFFFFF), onChanged: onChanged),
),
),
SizedBox(width: 26, child: Text('${value.toInt()}', textAlign: TextAlign.right, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 10, color: Glass.ink))),
]);
}
}
class _Dot extends StatelessWidget {
const _Dot(this.color, this.icon);
final Color color;
final String icon;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(color: color, shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 2)),
alignment: Alignment.center,
child: PVIcon(icon, size: 12, color: const Color(0xFF05060A)),
);
}
}
+228
View File
@@ -0,0 +1,228 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../dji_service.dart';
import '../flight_model.dart';
import '../pb_auth.dart';
import '../theme.dart';
import 'flight_logs_page.dart';
import 'pv_icons.dart';
import 'routes_page.dart';
/// Profile — mirrors the v2 "Profile" mockup. Shows the PilotVault identity and
/// (optionally) the linked DJI account, headline stats, and library shortcuts.
class ProfilePage extends StatefulWidget {
const ProfilePage({
super.key,
required this.model,
required this.dji,
required this.onAppSettings,
required this.onSignIn,
});
final FlightModel model;
final DjiService dji;
final VoidCallback onAppSettings;
final VoidCallback onSignIn;
@override
State<ProfilePage> createState() => _ProfilePageState();
}
class _ProfilePageState extends State<ProfilePage> {
StreamSubscription<AuthStatus>? _authSub;
DjiService get _dji => widget.dji;
FlightModel get _m => widget.model;
@override
void initState() {
super.initState();
_authSub = auth.status.listen((_) {
if (mounted) setState(() {});
});
widget.dji.refreshDjiAccountState().catchError((_) {});
}
@override
void dispose() {
_authSub?.cancel();
super.dispose();
}
void _snack(String msg) {
ScaffoldMessenger.of(context)
..clearSnackBars()
..showSnackBar(SnackBar(content: Text(msg)));
}
Future<void> _djiLogin() async {
try {
await _dji.djiLogin();
_snack('DJI account linked');
} catch (e) {
_snack('DJI login: ${e is PlatformException ? (e.message ?? e.code) : e}');
}
}
Future<void> _djiLogout() async {
try {
await _dji.djiLogout();
} catch (_) {}
}
bool get _djiLinked => _m.djiAccountState == 'AUTHORIZED';
@override
Widget build(BuildContext context) {
final PVScheme s = PVScheme.of(context);
final bool signedIn = auth.isAuthed;
return Scaffold(
backgroundColor: s.bgApp,
body: SafeArea(
child: AnimatedBuilder(
animation: _m,
builder: (BuildContext context, _) => ListView(
children: <Widget>[
_headerBar(s),
_identity(s, signedIn),
_stats(s),
_djiCard(s),
const SizedBox(height: 6),
..._rows(s),
],
),
),
),
);
}
Widget _headerBar(PVScheme s) => Padding(
padding: const EdgeInsets.fromLTRB(12, 6, 12, 0),
child: Row(children: <Widget>[
IconButton(onPressed: () => Navigator.of(context).maybePop(), icon: PVIcon('chevronLeft', size: 24, color: s.textSecondary)),
const Spacer(),
IconButton(onPressed: widget.onAppSettings, icon: PVIcon('settings', size: 20, color: s.textSecondary)),
]),
);
Widget _identity(PVScheme s, bool signedIn) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 6, 20, 16),
child: Row(children: <Widget>[
Container(
width: 60, height: 60,
decoration: BoxDecoration(color: s.accentSoft, shape: BoxShape.circle),
alignment: Alignment.center,
child: PVIcon('user', size: 30, color: s.accent),
),
const SizedBox(width: 14),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
Text(signedIn ? (auth.userEmail.isEmpty ? 'PilotVault pilot' : auth.userEmail) : 'Guest pilot',
maxLines: 1, overflow: TextOverflow.ellipsis,
style: TextStyle(fontFamily: PV.fontSans, fontSize: 18, fontWeight: FontWeight.w700, letterSpacing: -0.2, color: s.textPrimary)),
const SizedBox(height: 2),
Text(signedIn ? 'Verified pilot' : 'Not signed in',
style: TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: s.textSecondary)),
]),
),
if (!signedIn)
FilledButton(onPressed: widget.onSignIn, child: const Text('Sign in')),
]),
);
}
Widget _stats(PVScheme s) {
const List<(String, String)> stats = <(String, String)>[('Flights', ''), ('Distance', ''), ('Flight time', '')];
return Container(
margin: const EdgeInsets.fromLTRB(20, 0, 20, 18),
decoration: BoxDecoration(color: s.surface, borderRadius: BorderRadius.circular(16), border: Border.all(color: s.border), boxShadow: s.shadowXs),
child: Row(children: <Widget>[
for (int i = 0; i < stats.length; i++)
Expanded(
child: Container(
decoration: BoxDecoration(border: Border(left: i == 0 ? BorderSide.none : BorderSide(color: s.border))),
padding: const EdgeInsets.symmetric(vertical: 14),
child: Column(children: <Widget>[
Text(stats[i].$2, style: TextStyle(fontFamily: PV.fontMono, fontSize: 19, fontWeight: FontWeight.w700, color: s.textPrimary)),
const SizedBox(height: 2),
Text(stats[i].$1, style: TextStyle(fontFamily: PV.fontSans, fontSize: 11.5, color: s.textSecondary)),
]),
),
),
]),
);
}
Widget _djiCard(PVScheme s) {
return Container(
margin: const EdgeInsets.fromLTRB(20, 0, 20, 8),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(color: s.surface, borderRadius: BorderRadius.circular(14), border: Border.all(color: s.border)),
child: Row(children: <Widget>[
Container(
width: 34, height: 34,
decoration: BoxDecoration(color: s.surfaceInset, borderRadius: BorderRadius.circular(9)),
alignment: Alignment.center,
child: PVIcon('drone', size: 18, color: s.textSecondary),
),
const SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
Text('DJI account', style: TextStyle(fontFamily: PV.fontSans, fontSize: 14, fontWeight: FontWeight.w600, color: s.textPrimary)),
Text(_djiLinked ? (_m.djiAccountUser ?? 'Linked') : 'Optional · unlocks NFZ & sync',
style: TextStyle(fontFamily: PV.fontMono, fontSize: 11, color: s.textSecondary)),
])),
_djiLinked
? TextButton(onPressed: _djiLogout, child: const Text('Unlink'))
: FilledButton(onPressed: _djiLogin, child: const Text('Link')),
]),
);
}
List<Widget> _rows(PVScheme s) {
final List<(String, String, VoidCallback)> rows = <(String, String, VoidCallback)>[
('gauge', 'Flight records', () => _push(const FlightLogsPage())),
('route', 'My routes', () => _push(const RoutesPage())),
('download', 'Downloads', () => _snack('Downloaded media is saved to the app files folder')),
('shield', 'Find my drone', _findDrone),
('settings', 'App settings', widget.onAppSettings),
];
return <Widget>[
for (int i = 0; i < rows.length; i++)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: InkWell(
onTap: rows[i].$3,
child: Container(
height: 52,
decoration: BoxDecoration(border: Border(bottom: i < rows.length - 1 ? BorderSide(color: s.border) : BorderSide.none)),
child: Row(children: <Widget>[
Container(
width: 34, height: 34,
decoration: BoxDecoration(color: s.surfaceInset, borderRadius: BorderRadius.circular(9)),
alignment: Alignment.center,
child: PVIcon(rows[i].$1, size: 17, color: s.textSecondary),
),
const SizedBox(width: 14),
Expanded(child: Text(rows[i].$2, style: TextStyle(fontFamily: PV.fontSans, fontSize: 14.5, fontWeight: FontWeight.w500, color: s.textPrimary))),
PVIcon('chevronRight', size: 18, color: s.textTertiary),
]),
),
),
),
];
}
void _push(Widget page) => Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => page));
void _findDrone() {
if (_m.latitude != null && _m.longitude != null) {
_snack('Aircraft at ${_m.latitude!.toStringAsFixed(5)}, ${_m.longitude!.toStringAsFixed(5)}');
} else {
_snack('No aircraft GPS fix available');
}
}
}
+131
View File
@@ -0,0 +1,131 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
/// The v2 UI-kit line-icon library.
///
/// Ported verbatim from the `P = {…}` SVG-path map in
/// `Design/PilotVault Project logo/ui_kits/fly/index-v2.html`. Every icon is a
/// single `d` attribute drawn on a 24×24 grid with round caps/joins, matching
/// the kit's `<Icon>` component. Rendered through [PVIcon].
class PvPaths {
PvPaths._();
static const Map<String, String> d = <String, String>{
// nav / system
'chevronLeft': 'M15 6l-6 6 6 6',
'chevronRight': 'M9 6l6 6-6 6',
'chevronDown': 'M6 9l6 6 6-6',
'close': 'M6 6l12 12M18 6L6 18',
'more': 'M5 12h.01M12 12h.01M19 12h.01',
'home': 'M3 10.5 12 3l9 7.5M5 9.5V21h14V9.5',
'settings':
'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z',
// flight status
'satellite':
'M13 7 9 3 5 7l4 4M17 11l4 4-4 4-4-4M8.5 8.5 3 14l7 7 5.5-5.5M16 16l1-1M18 3a3 3 0 0 1 3 3',
'battery': 'M3 8h14a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H3a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1zM22 11v2',
'radio':
'M4.9 19.1a10 10 0 0 1 0-14.2M7.8 16.2a6 6 0 0 1 0-8.4M16.2 7.8a6 6 0 0 1 0 8.4M19.1 4.9a10 10 0 0 1 0 14.2M12 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2z',
'rc': 'M7 8h10a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-6a3 3 0 0 1 3-3zM8 4v4M16 4v4M9 14h2M8 13v2M15 13h.01M17 15h.01',
'obstacle': 'M12 3 2 20h20L12 3zM12 10v4M12 17h.01',
'compass': 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM15.5 8.5l-2 5-5 2 2-5 5-2z',
'gimbal': 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM12 8v8M8 12h8',
'crosshair': 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM22 12h-4M6 12H2M12 6V2M12 22v-4',
'drone':
'M6 6l3.5 3.5M18 6l-3.5 3.5M6 18l3.5-3.5M18 18l-3.5-3.5M6 6a2.5 2.5 0 1 0-.01-.01M18 6a2.5 2.5 0 1 0-.01 0M6 18a2.5 2.5 0 1 0-.01 0M18 18a2.5 2.5 0 1 0-.01 0M9.5 9.5h5v5h-5z',
'takeoff': 'M12 20V8m0 0-4 4m4-4 4 4M4 4h16',
'rth': 'M12 3a9 9 0 1 0 9 9M12 3v6l4 2M20 4l-3 1 1-3',
'map': 'M9 4 3 6v14l6-2 6 2 6-2V4l-6 2-6-2zM9 4v14M15 6v14',
'pin': 'M12 21s7-6.4 7-12A7 7 0 0 0 5 9c0 5.6 7 12 7 12zM12 11a2 2 0 1 0 0-4 2 2 0 0 0 0 4z',
'route': 'M6 19a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM18 11a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM6 13V9a4 4 0 0 1 4-4h4',
// camera
'video': 'M23 7l-7 5 7 5V7zM3 5h11a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2z',
'image': 'M3 3h18v18H3zM8.5 10a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zM21 15l-5-5L5 21',
'aperture':
'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM14.3 8 20 8M12.6 4.5 9.8 9.3M7 6 10 11.2M9.7 16 4 16M11.4 19.5l2.8-4.8M17 18l-3-5.2',
'iso': 'M4 7v10M8 7c-2 0-2 5 0 5s2 5 0 5M14 7a3 5 0 0 1 0 10 3 5 0 0 1 0-10M19 7v10',
'shutter': 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 12l6-3M12 12l-3 6M12 12l-3-6M12 12l6 3',
'ev': 'M4 6h10M4 12h7M4 18h10M17 9v6M20 12h-6',
'wb': 'M12 17a5 5 0 1 0 0-10 5 5 0 0 0 0 10zM12 2v3M12 19v3M5 12H2M22 12h-3',
'timer': 'M12 22a8 8 0 1 0 0-16 8 8 0 0 0 0 16zM12 10v4l2 2M9 2h6',
'burst': 'M7 8h10v10H7zM5 6h10M5 10v6',
'film': 'M3 3h18v18H3zM7 3v18M17 3v18M3 8h4M3 16h4M17 8h4M17 16h4',
'pano': 'M2 7l20-3v16L2 17V7zM2 7v10',
'grid': 'M3 3h18v18H3zM3 9h18M3 15h18M9 3v18M15 3v18',
'histogram': 'M4 20V10M9 20V4M14 20v-8M19 20v-6',
'focus':
'M3 8V5a2 2 0 0 1 2-2h3M16 3h3a2 2 0 0 1 2 2v3M21 16v3a2 2 0 0 1-2 2h-3M8 21H5a2 2 0 0 1-2-2v-3M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z',
'slowmo': 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM10 9v6l5-3z',
'hyperlapse': 'M12 8v4l3 2M4 12a8 8 0 1 1 3 6M4 20v-4h4',
'master': 'M12 3l2.6 5.3 5.9.9-4.3 4.1 1 5.8-5.2-2.7-5.2 2.7 1-5.8L3.5 9.2l5.9-.9L12 3z',
'hdr': 'M4 8v8M4 12h4M8 8v8M12 8v8h2a3 3 0 0 0 0-6M12 12h2M18 8v8M18 12h2l1 4',
// quickshots
'dronie': 'M12 4a3 3 0 1 0 0 6 3 3 0 0 0 0-6zM4 20l8-8 8 8',
'rocket': 'M12 3c4 2 5 7 5 10l-2 3H9l-2-3c0-3 1-8 5-10zM12 9v.01M8 17l-3 4M16 17l3 4',
'circle': 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z',
'helix': 'M6 3c12 3-12 15 0 18M6 6c9 2.2-9 11.6 0 13.5',
'boomerang': 'M4 20C4 10 10 4 20 4c0 10-6 16-16 16zM4 20l6-6',
'asteroid': 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM3 12h18M8 4c-2 2.5-2 13.5 0 16M16 4c2 2.5 2 13.5 0 16',
// home / library / misc
'academy': 'M22 10 12 5 2 10l10 5 10-5zM6 12v5c0 1 2.7 2.5 6 2.5s6-1.5 6-2.5v-5',
'album': 'M3 3h18v18H3zM8.5 10a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zM21 15l-5-5L5 21',
'gauge': 'M12 15a3 3 0 1 0 0-6M3.5 18a9 9 0 1 1 17 0',
'user': 'M20 21a8 8 0 1 0-16 0M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z',
'sdcard': 'M18 2H8L4 6v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2zM10 6v2M13 6v2M16 6v2',
'link': 'M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1',
'play': 'M8 5v14l11-7z',
'pause': 'M8 5v14M16 5v14',
'sun': 'M12 17a5 5 0 1 0 0-10 5 5 0 0 0 0 10zM12 1v2M12 21v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M1 12h2M21 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4',
'bell': 'M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9M13.73 21a2 2 0 0 1-3.46 0',
'share': 'M4 12v8a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-8M16 6l-4-4-4 4M12 2v13',
'search': 'M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16zM21 21l-4.3-4.3',
'check': 'M20 6 9 17l-5-5',
'shield': 'M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6l8-3z',
'download': 'M12 3v12m0 0-4-4m4 4 4-4M4 21h16',
'play2': 'M8 5v14l11-7z',
};
}
/// A single v2 line icon. Strokes one of [PvPaths.d] on a 24×24 grid, round
/// caps/joins, matching the design's `<Icon>` component.
class PVIcon extends StatelessWidget {
const PVIcon(
this.name, {
super.key,
this.size = 20,
this.color = Colors.white,
this.stroke = 2,
this.fill = false,
});
final String name;
final double size;
final Color color;
final double stroke;
/// When true (e.g. play triangles), the path is filled with [color] and drawn
/// with no stroke — matches the kit's `fill="#fff" stroke="#fff"` usage.
final bool fill;
@override
Widget build(BuildContext context) {
final String d = PvPaths.d[name] ?? '';
final String hex = _hex(color);
final String fillAttr = fill ? hex : 'none';
final String strokeAttr = fill ? 'none' : hex;
final String svg =
'<svg xmlns="http://www.w3.org/2000/svg" width="$size" height="$size" '
'viewBox="0 0 24 24" fill="$fillAttr" stroke="$strokeAttr" '
'stroke-width="$stroke" stroke-linecap="round" stroke-linejoin="round">'
'<path d="$d"/></svg>';
return SvgPicture.string(svg, width: size, height: size);
}
static String _hex(Color c) {
final int r = (c.r * 255).round();
final int g = (c.g * 255).round();
final int b = (c.b * 255).round();
String two(int v) => v.toRadixString(16).padLeft(2, '0');
return '#${two(r)}${two(g)}${two(b)}';
}
}
+16
View File
@@ -0,0 +1,16 @@
import 'package:flutter/material.dart';
import 'simple_list_page.dart';
/// Saved waypoint routes. Routes are built live on the Map screen; a persisted
/// route library is a later add, so this shows the empty state for now.
class RoutesPage extends StatelessWidget {
const RoutesPage({super.key});
@override
Widget build(BuildContext context) => const SimpleListPage(
title: 'My routes',
emptyIcon: 'route',
emptyText: 'No saved routes yet.\nCreate one on the map during flight.',
);
}
+279
View File
@@ -0,0 +1,279 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../dji_service.dart';
import '../flight_model.dart';
import '../theme.dart';
import 'pv_icons.dart';
/// Aircraft settings — mirrors the v2 "Settings" mockup. Left tab column
/// (Safety / Control / Camera / Transmission / About); Safety & Control rows
/// drive real flight-controller setters. Values are held on the model and
/// updated as the user changes them (sensible defaults until read back).
class SettingsMenuPage extends StatefulWidget {
const SettingsMenuPage({super.key, required this.model, required this.dji});
final FlightModel model;
final DjiService dji;
@override
State<SettingsMenuPage> createState() => _SettingsMenuPageState();
}
class _SettingsMenuPageState extends State<SettingsMenuPage> {
DjiService get _dji => widget.dji;
FlightModel get _m => widget.model;
int _tab = 0;
static const List<(String, String)> _tabs = <(String, String)>[
('shield', 'Safety'),
('rc', 'Control'),
('aperture', 'Camera'),
('radio', 'Transmission'),
('drone', 'About'),
];
@override
void initState() {
super.initState();
// Seed defaults where nothing has been read back yet.
_m.maxHeight ??= 120;
_m.maxRadius ??= 500;
_m.maxRadiusEnabled ??= false;
_m.rthHeight ??= 100;
_m.obstacleAvoidance ??= 'On';
_m.noviceMode ??= false;
}
void _snack(String msg) {
ScaffoldMessenger.of(context)
..clearSnackBars()
..showSnackBar(SnackBar(content: Text(msg)));
}
Future<void> _run(String label, Future<void> Function() action) async {
try {
await action();
} catch (e) {
_snack('$label: ${e is PlatformException ? (e.message ?? e.code) : e}');
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF0A1120),
body: SafeArea(
child: AnimatedBuilder(
animation: _m,
builder: (BuildContext context, _) => Stack(children: <Widget>[
Row(children: <Widget>[
_tabColumn(),
Expanded(child: _rightPanel()),
]),
Positioned(
top: 6, right: 8,
child: GestureDetector(
onTap: () => Navigator.of(context).maybePop(),
child: Container(
width: 30, height: 30,
decoration: BoxDecoration(color: const Color(0x1FFFFFFF), borderRadius: BorderRadius.circular(9)),
alignment: Alignment.center,
child: const PVIcon('close', size: 17, color: Glass.ink),
),
),
),
]),
),
),
);
}
Widget _tabColumn() {
return Container(
width: 160,
padding: const EdgeInsets.fromLTRB(12, 18, 12, 12),
color: const Color(0x8C081020),
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
Padding(
padding: const EdgeInsets.only(left: 6, bottom: 10),
child: Row(children: const <Widget>[
PVIcon('settings', size: 18, color: Glass.ink),
SizedBox(width: 8),
Text('Settings', style: TextStyle(fontFamily: PV.fontSans, fontSize: 15, fontWeight: FontWeight.w700, color: Glass.ink)),
]),
),
for (int i = 0; i < _tabs.length; i++) _tabBtn(i),
]),
);
}
Widget _tabBtn(int i) {
final bool active = i == _tab;
return GestureDetector(
onTap: () => setState(() => _tab = i),
child: Container(
height: 38,
margin: const EdgeInsets.only(bottom: 6),
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(color: active ? Glass.accent : Colors.transparent, borderRadius: BorderRadius.circular(10)),
child: Row(children: <Widget>[
PVIcon(_tabs[i].$1, size: 17, stroke: 1.7, color: Glass.ink),
const SizedBox(width: 10),
Text(_tabs[i].$2, style: const TextStyle(fontFamily: PV.fontSans, fontSize: 13, fontWeight: FontWeight.w600, color: Glass.ink)),
]),
),
);
}
Widget _rightPanel() {
final (String eyebrow, List<Widget> rows) = switch (_tab) {
0 => ('Flight Safety', _safetyRows()),
1 => ('Flight Control', _controlRows()),
2 => ('Camera', _cameraRows()),
3 => ('Transmission', _infoRows(<(String, String)>[('Channel Mode', 'Auto'), ('Frequency', '2.4 / 5.8 GHz'), ('Signal', 'HD 1080p')])),
_ => ('About', _infoRows(<(String, String)>[('Model', _m.model ?? ''), ('Firmware', _m.firmwareVersion ?? ''), ('MSDK', _m.sdkVersion)])),
};
return Padding(
padding: const EdgeInsets.fromLTRB(22, 20, 22, 20),
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(eyebrow.toUpperCase(),
style: const TextStyle(fontFamily: PV.fontMono, fontSize: 10, letterSpacing: 1.4, color: Color(0x8CEAF0FA))),
),
Expanded(child: ListView(children: rows)),
]),
);
}
// ── Rows ────────────────────────────────────────────────────────────────
List<Widget> _safetyRows() => <Widget>[
_stepperRow('Max Altitude', '${_m.maxHeight} m', () => _editNumber('Max Altitude', _m.maxHeight ?? 120, 20, 500, 10, (int v) {
setState(() => _m.maxHeight = v);
_run('Max altitude', () => _dji.setMaxFlightHeight(v));
})),
_toggleRow('Max Distance', _m.maxRadiusEnabled ?? false, (bool on) {
setState(() => _m.maxRadiusEnabled = on);
_run('Max distance', () => _dji.setMaxRadiusEnabled(on));
}),
_stepperRow('Return-to-Home Alt.', '${_m.rthHeight} m', () => _editNumber('RTH Altitude', _m.rthHeight ?? 100, 20, 500, 10, (int v) {
setState(() => _m.rthHeight = v);
_run('RTH altitude', () => _dji.setGoHomeHeight(v));
})),
_cycleRow('Obstacle Avoidance', _m.obstacleAvoidance ?? 'On', <String>['On', 'Off'], (String v) {
setState(() => _m.obstacleAvoidance = v);
_run('Obstacle avoidance', () => _dji.setObstacleAvoidance(v == 'On'));
}),
_toggleRow('Beginner Mode', _m.noviceMode ?? false, (bool on) {
setState(() => _m.noviceMode = on);
_run('Beginner mode', () => _dji.setNoviceMode(on));
}),
_toggleRow('AR Home Point', _m.arHomePoint, (bool on) => setState(() => _m.arHomePoint = on)),
];
List<Widget> _controlRows() => <Widget>[
_stepperRow('Max Distance', '${_m.maxRadius} m', () => _editNumber('Max Distance', _m.maxRadius ?? 500, 50, 5000, 50, (int v) {
setState(() => _m.maxRadius = v);
_run('Max distance', () => _dji.setMaxFlightRadius(v));
})),
_infoRow('Set Home to Current', 'Tap', onTap: () => _run('Set home', _dji.setHomeToCurrent)),
_infoRow('Cancel Return-to-Home', 'Tap', onTap: () => _run('Cancel RTH', _dji.cancelGoHome)),
];
List<Widget> _cameraRows() => <Widget>[
_infoRow('Exposure', _m.exposureProgram == ExposureProgram.pro ? 'Pro' : 'Auto'),
_infoRow('ISO', _m.iso ?? ''),
_infoRow('Shutter', _m.shutter ?? ''),
_infoRow('White Balance', _m.whiteBalance ?? 'Auto'),
];
List<Widget> _infoRows(List<(String, String)> items) =>
<Widget>[for (final (String l, String v) in items) _infoRow(l, v)];
Widget _rowShell({required Widget child}) => Container(
height: 44,
decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: Color(0x12FFFFFF)))),
child: child,
);
Widget _label(String l) => Text(l, style: const TextStyle(fontFamily: PV.fontSans, fontSize: 13.5, color: Glass.ink));
Widget _stepperRow(String label, String value, VoidCallback onTap) => _rowShell(
child: InkWell(
onTap: onTap,
child: Row(children: <Widget>[
Expanded(child: _label(label)),
Text(value, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 13, fontWeight: FontWeight.w700, color: Color(0xFF8FB4F6))),
const SizedBox(width: 4),
const PVIcon('chevronRight', size: 15, color: Color(0xFF8FB4F6)),
]),
),
);
Widget _infoRow(String label, String value, {VoidCallback? onTap}) => _rowShell(
child: InkWell(
onTap: onTap,
child: Row(children: <Widget>[
Expanded(child: _label(label)),
Text(value, style: TextStyle(fontFamily: PV.fontMono, fontSize: 13, color: onTap == null ? const Color(0x99EAF0FA) : const Color(0xFF8FB4F6))),
]),
),
);
Widget _cycleRow(String label, String value, List<String> options, ValueChanged<String> onChanged) => _rowShell(
child: InkWell(
onTap: () {
final int i = (options.indexOf(value) + 1) % options.length;
onChanged(options[i]);
},
child: Row(children: <Widget>[
Expanded(child: _label(label)),
Text(value, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 13, fontWeight: FontWeight.w700, color: Color(0xFF8FB4F6))),
const SizedBox(width: 4),
const PVIcon('chevronRight', size: 15, color: Color(0xFF8FB4F6)),
]),
),
);
Widget _toggleRow(String label, bool value, ValueChanged<bool> onChanged) => _rowShell(
child: Row(children: <Widget>[
Expanded(child: _label(label)),
GestureDetector(
onTap: () => onChanged(!value),
child: Container(
width: 40, height: 22,
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(color: value ? const Color(0xFF3D7BF0) : const Color(0x26FFFFFF), borderRadius: BorderRadius.circular(999)),
alignment: value ? Alignment.centerRight : Alignment.centerLeft,
child: Container(width: 18, height: 18, decoration: const BoxDecoration(color: Colors.white, shape: BoxShape.circle)),
),
),
]),
);
Future<void> _editNumber(String title, int initial, int min, int max, int step, ValueChanged<int> onSet) async {
int value = initial;
await showDialog<void>(
context: context,
builder: (BuildContext ctx) => StatefulBuilder(
builder: (BuildContext ctx, StateSetter set) => AlertDialog(
backgroundColor: const Color(0xFF10203F),
title: Text(title, style: const TextStyle(color: Glass.ink, fontFamily: PV.fontSans, fontWeight: FontWeight.w700)),
content: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: <Widget>[
IconButton(onPressed: () => set(() => value = (value - step).clamp(min, max)), icon: const Icon(Icons.remove, color: Glass.ink)),
Text('$value m', style: const TextStyle(fontFamily: PV.fontMono, fontSize: 22, fontWeight: FontWeight.w700, color: Glass.ink)),
IconButton(onPressed: () => set(() => value = (value + step).clamp(min, max)), icon: const Icon(Icons.add, color: Glass.ink)),
]),
actions: <Widget>[
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancel')),
FilledButton(onPressed: () {
Navigator.pop(ctx);
onSet(value);
}, child: const Text('Set')),
],
),
),
);
}
}
+54
View File
@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import '../theme.dart';
import 'pv_icons.dart';
/// A plain portrait scaffold with a back title and a centered empty state —
/// shared chrome for the Routes and Flight-logs library screens (which have no
/// persisted content yet).
class SimpleListPage extends StatelessWidget {
const SimpleListPage({
super.key,
required this.title,
required this.emptyIcon,
required this.emptyText,
this.child,
});
final String title;
final String emptyIcon;
final String emptyText;
final Widget? child;
@override
Widget build(BuildContext context) {
final PVScheme s = PVScheme.of(context);
return Scaffold(
backgroundColor: s.bgApp,
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Padding(
padding: const EdgeInsets.fromLTRB(12, 6, 20, 12),
child: Row(children: <Widget>[
IconButton(onPressed: () => Navigator.of(context).maybePop(), icon: PVIcon('chevronLeft', size: 24, color: s.textSecondary)),
Text(title, style: TextStyle(fontFamily: PV.fontSans, fontSize: 20, fontWeight: FontWeight.w700, letterSpacing: -0.4, color: s.textPrimary)),
]),
),
Expanded(
child: child ??
Center(
child: Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
PVIcon(emptyIcon, size: 40, stroke: 1.4, color: s.textTertiary),
const SizedBox(height: 12),
Text(emptyText, textAlign: TextAlign.center, style: TextStyle(fontFamily: PV.fontSans, fontSize: 14, color: s.textSecondary)),
]),
),
),
],
),
),
);
}
}
+250 -2
View File
@@ -89,6 +89,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.8" version: "1.0.8"
dart_earcut:
dependency: transitive
description:
name: dart_earcut
sha256: e485001bfc05dcbc437d7bfb666316182e3522d4c3f9668048e004d0eb2ce43b
url: "https://pub.dev"
source: hosted
version: "1.2.0"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
@@ -142,6 +150,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.0.0" version: "5.0.0"
flutter_map:
dependency: "direct main"
description:
name: flutter_map
sha256: "2ecb34619a4be19df6f40c2f8dce1591675b4eff7a6857bd8f533706977385da"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
flutter_plugin_android_lifecycle: flutter_plugin_android_lifecycle:
dependency: transitive dependency: transitive
description: description:
@@ -150,6 +166,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.35" version: "2.0.35"
flutter_svg:
dependency: "direct main"
description:
name: flutter_svg
sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f"
url: "https://pub.dev"
source: hosted
version: "2.3.0"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@@ -208,6 +232,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.2.5" version: "0.2.5"
http:
dependency: transitive
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
image: image:
dependency: transitive dependency: transitive
description: description:
@@ -224,6 +264,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.20.3" version: "0.20.3"
jni:
dependency: transitive
description:
name: jni
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
url: "https://pub.dev"
source: hosted
version: "1.0.0"
jni_flutter:
dependency: transitive
description:
name: jni_flutter
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
json_annotation: json_annotation:
dependency: transitive dependency: transitive
description: description:
@@ -232,6 +288,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.12.0" version: "4.12.0"
latlong2:
dependency: "direct main"
description:
name: latlong2
sha256: "98227922caf49e6056f91b6c56945ea1c7b166f28ffcd5fb8e72fc0b453cc8fe"
url: "https://pub.dev"
source: hosted
version: "0.9.1"
leak_tracker: leak_tracker:
dependency: transitive dependency: transitive
description: description:
@@ -264,6 +328,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.1.1" version: "5.1.1"
lists:
dependency: transitive
description:
name: lists
sha256: "4ca5c19ae4350de036a7e996cdd1ee39c93ac0a2b840f4915459b7d0a7d4ab27"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
local_auth: local_auth:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -304,6 +376,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.11" version: "1.0.11"
logger:
dependency: transitive
description:
name: logger
sha256: "25aee487596a6257655a1e091ec2ae66bc30e7af663592cc3a27e6591e05035c"
url: "https://pub.dev"
source: hosted
version: "2.7.0"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:
@@ -328,6 +408,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.18.0" version: "1.18.0"
mgrs_dart:
dependency: transitive
description:
name: mgrs_dart
sha256: fb89ae62f05fa0bb90f70c31fc870bcbcfd516c843fb554452ab3396f78586f7
url: "https://pub.dev"
source: hosted
version: "2.0.0"
package_config:
dependency: transitive
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.dev"
source: hosted
version: "2.2.0"
path: path:
dependency: transitive dependency: transitive
description: description:
@@ -336,6 +432,38 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.1" version: "1.9.1"
path_parsing:
dependency: transitive
description:
name: path_parsing
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
path_provider:
dependency: "direct main"
description:
name: path_provider
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
url: "https://pub.dev"
source: hosted
version: "2.1.6"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
url: "https://pub.dev"
source: hosted
version: "2.3.1"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4"
url: "https://pub.dev"
source: hosted
version: "2.5.1"
path_provider_linux: path_provider_linux:
dependency: transitive dependency: transitive
description: description:
@@ -384,6 +512,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.8" version: "2.1.8"
polylabel:
dependency: transitive
description:
name: polylabel
sha256: "41b9099afb2aa6c1730bdd8a0fab1400d287694ec7615dd8516935fa3144214b"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
posix: posix:
dependency: transitive dependency: transitive
description: description:
@@ -392,6 +528,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.5.0" version: "6.5.0"
proj4dart:
dependency: transitive
description:
name: proj4dart
sha256: c8a659ac9b6864aa47c171e78d41bbe6f5e1d7bd790a5814249e6b68bc44324e
url: "https://pub.dev"
source: hosted
version: "2.1.0"
shared_preferences: shared_preferences:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -509,6 +653,78 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.0"
unicode:
dependency: transitive
description:
name: unicode
sha256: "0f69e46593d65245774d4f17125c6084d2c20b4e473a983f6e21b7d7762218f1"
url: "https://pub.dev"
source: hosted
version: "0.3.1"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.dev"
source: hosted
version: "6.3.2"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32
url: "https://pub.dev"
source: hosted
version: "6.3.32"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
url: "https://pub.dev"
source: hosted
version: "6.4.1"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
url: "https://pub.dev"
source: hosted
version: "3.2.2"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
url: "https://pub.dev"
source: hosted
version: "3.2.5"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
url: "https://pub.dev"
source: hosted
version: "2.4.3"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
url: "https://pub.dev"
source: hosted
version: "3.1.5"
uuid: uuid:
dependency: transitive dependency: transitive
description: description:
@@ -517,6 +733,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.5.3" version: "4.5.3"
vector_graphics:
dependency: transitive
description:
name: vector_graphics
sha256: "2306c03da2ba81724afeb589c351ebbc0aa7d86005925be8f8735856dbe5e42d"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
vector_graphics_codec:
dependency: transitive
description:
name: vector_graphics_codec
sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146"
url: "https://pub.dev"
source: hosted
version: "1.1.13"
vector_graphics_compiler:
dependency: transitive
description:
name: vector_graphics_compiler
sha256: "142a9146f447d15b10bdc00e21d5f4d83e5b32bb5f8f8f5a04c75311344923a3"
url: "https://pub.dev"
source: hosted
version: "1.2.6"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
@@ -541,6 +781,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
wkt_parser:
dependency: transitive
description:
name: wkt_parser
sha256: "8a555fc60de3116c00aad67891bcab20f81a958e4219cc106e3c037aa3937f13"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
xdg_directories: xdg_directories:
dependency: transitive dependency: transitive
description: description:
@@ -566,5 +814,5 @@ packages:
source: hosted source: hosted
version: "3.1.3" version: "3.1.3"
sdks: sdks:
dart: ">=3.11.0 <4.0.0" dart: ">=3.12.0 <4.0.0"
flutter: ">=3.38.0" flutter: ">=3.44.0"
+14
View File
@@ -47,6 +47,20 @@ dependencies:
# App's automatic bounding box when the drone has no fix. # App's automatic bounding box when the drone has no fix.
geolocator: ^13.0.1 geolocator: ^13.0.1
# Renders the v2 UI-kit line icons (SVG path strings ported into pv_icons.dart).
flutter_svg: ^2.0.10+1
# Map + real GPS waypoints for the landscape Map/Waypoints screen. OpenStreetMap
# tiles need no API key (unlike google_maps_flutter).
flutter_map: ^7.0.2
latlong2: ^0.9.1
# App cache / files dirs for MediaManager thumbnails and downloads.
path_provider: ^2.1.4
# Opens Academy reference links (tutorials / manuals) in the browser.
url_launcher: ^6.3.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter