diff --git a/Fly App/android/app/build.gradle b/Fly App/android/app/build.gradle index a481b6f..9805838 100644 --- a/Fly App/android/app/build.gradle +++ b/Fly App/android/app/build.gradle @@ -124,6 +124,21 @@ dependencies { 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 ------------------------------------ // Flutter's `compileFlutterBuild` task declares an output directory // that overlaps the Android source sets, so Gradle 8's execution-time diff --git a/Fly App/android/app/src/main/AndroidManifest.xml b/Fly App/android/app/src/main/AndroidManifest.xml index cc74347..17f893a 100644 --- a/Fly App/android/app/src/main/AndroidManifest.xml +++ b/Fly App/android/app/src/main/AndroidManifest.xml @@ -16,7 +16,11 @@ - + + + + diff --git a/Fly App/android/app/src/main/kotlin/com/dji/flutter/dji_msdk_sample/AccountBridge.kt b/Fly App/android/app/src/main/kotlin/com/dji/flutter/dji_msdk_sample/AccountBridge.kt new file mode 100644 index 0000000..2fb2307 --- /dev/null +++ b/Fly App/android/app/src/main/kotlin/com/dji/flutter/dji_msdk_sample/AccountBridge.kt @@ -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 { + 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 { + 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)) + } +} diff --git a/Fly App/android/app/src/main/kotlin/com/dji/flutter/dji_msdk_sample/CameraBridge.kt b/Fly App/android/app/src/main/kotlin/com/dji/flutter/dji_msdk_sample/CameraBridge.kt new file mode 100644 index 0000000..f84b2b3 --- /dev/null +++ b/Fly App/android/app/src/main/kotlin/com/dji/flutter/dji_msdk_sample/CameraBridge.kt @@ -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("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(call.argument("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("program") == "pro") + SettingsDefinitions.ExposureMode.MANUAL + else SettingsDefinitions.ExposureMode.PROGRAM + it.setExposureMode(mode, ctx.completion(result)) + } + "setISO" -> guard(camera, result) { + val v = enumOrNull(isoName(call.argument("value"))) + if (v == null) ctx.fail(result, "Unsupported ISO") else it.setISO(v, ctx.completion(result)) + } + "setShutterSpeed" -> guard(camera, result) { + val v = enumOrNull(shutterName(call.argument("value"))) + if (v == null) ctx.fail(result, "Unsupported shutter") else it.setShutterSpeed(v, ctx.completion(result)) + } + "setAperture" -> guard(camera, result) { + val v = enumOrNull(apertureName(call.argument("value"))) + if (v == null) ctx.fail(result, "Unsupported aperture") else it.setAperture(v, ctx.completion(result)) + } + "setEV" -> guard(camera, result) { + val v = enumOrNull(evName(call.argument("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("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 > enumOrNull(name: String?): T? { + if (name == null) return null + return try { + enumValueOf(name) + } catch (_: IllegalArgumentException) { + null + } + } +} diff --git a/Fly App/android/app/src/main/kotlin/com/dji/flutter/dji_msdk_sample/DjiSdkBridge.kt b/Fly App/android/app/src/main/kotlin/com/dji/flutter/dji_msdk_sample/DjiSdkBridge.kt index f53581d..91bbf40 100644 --- a/Fly App/android/app/src/main/kotlin/com/dji/flutter/dji_msdk_sample/DjiSdkBridge.kt +++ b/Fly App/android/app/src/main/kotlin/com/dji/flutter/dji_msdk_sample/DjiSdkBridge.kt @@ -6,7 +6,7 @@ import android.os.Looper import dji.common.battery.BatteryState import dji.common.error.DJIError import dji.common.error.DJISDKError -import dji.common.flightcontroller.FlightControllerState +import dji.common.util.CommonCallbacks import dji.sdk.base.BaseComponent import dji.sdk.base.BaseProduct import dji.sdk.products.Aircraft @@ -20,8 +20,11 @@ import io.flutter.plugin.common.MethodChannel /** * Bridges the DJI Mobile SDK V4 to Flutter. * - * - [METHOD_CHANNEL] handles imperative calls from Dart (register, connect, query). - * - [EVENT_CHANNEL] streams registration / connection / telemetry updates to Dart. + * - [METHOD_CHANNEL] handles imperative calls from Dart, routed to per-subsystem + * 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 * the main thread before being pushed into the Flutter [EventChannel.EventSink]. @@ -42,6 +45,23 @@ class DjiSdkBridge( 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 { methodChannel.setMethodCallHandler(this) eventChannel.setStreamHandler(this) @@ -50,28 +70,35 @@ class DjiSdkBridge( // ── MethodChannel ────────────────────────────────────────────────────────── override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + // Core (SDK lifecycle) methods first, then delegate to subsystem helpers. when (call.method) { - "getSdkVersion" -> + "getSdkVersion" -> { result.success(DJISDKManager.getInstance().sdkVersion) - + return + } "registerApp" -> { registerApp() result.success(null) + return } - - "startConnection" -> + "startConnection" -> { result.success(DJISDKManager.getInstance().startConnectionToProduct()) - + return + } "stopConnection" -> { DJISDKManager.getInstance().stopConnectionToProduct() result.success(null) + return } - - "getProductInfo" -> + "getProductInfo" -> { result.success(connectionMap(DJISDKManager.getInstance().product)) - - else -> result.notImplemented() + return + } } + for (sub in subBridges) { + if (sub.handle(call, result)) return + } + result.notImplemented() } // ── EventChannel ─────────────────────────────────────────────────────────── @@ -100,7 +127,6 @@ class DjiSdkBridge( override fun onRegister(error: DJIError?) { if (error == DJISDKError.REGISTRATION_SUCCESS) { emit(mapOf("type" to "registration", "state" to "success")) - // Begin scanning for an attached product (USB RC / Wi-Fi). DJISDKManager.getInstance().startConnectionToProduct() } else { emit( @@ -132,8 +158,6 @@ class DjiSdkBridge( oldComponent: BaseComponent?, newComponent: BaseComponent?, ) { - // A component (e.g. flight controller, battery) appeared/changed — - // (re)attach the telemetry callbacks. bindComponentCallbacks(DJISDKManager.getInstance().product) } @@ -151,30 +175,20 @@ class DjiSdkBridge( private fun connectionMap(product: BaseProduct?): Map { val connected = product != null && product.isConnected 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?) { - if (product !is Aircraft) return + for (sub in subBridges) sub.bind(product) - product.flightController?.setStateCallback { state: FlightControllerState -> - 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, - ) - ) - } + if (product !is Aircraft) return @Suppress("DEPRECATION") product.battery?.setStateCallback { batteryState: BatteryState -> @@ -182,8 +196,49 @@ class DjiSdkBridge( mapOf( "type" to "battery", "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) -> 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 { + return object : CommonCallbacks.CompletionCallback { + 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?) {} +} diff --git a/Fly App/android/app/src/main/kotlin/com/dji/flutter/dji_msdk_sample/FlightControllerBridge.kt b/Fly App/android/app/src/main/kotlin/com/dji/flutter/dji_msdk_sample/FlightControllerBridge.kt new file mode 100644 index 0000000..32d25ee --- /dev/null +++ b/Fly App/android/app/src/main/kotlin/com/dji/flutter/dji_msdk_sample/FlightControllerBridge.kt @@ -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("value") ?: 120), ctx.completion(result)) + } + "setMaxFlightRadius" -> guard(controller, result) { + it.setMaxFlightRadius((call.argument("value") ?: 500), ctx.completion(result)) + } + "setMaxRadiusEnabled" -> guard(controller, result) { + it.setMaxFlightRadiusLimitationEnabled( + call.argument("value") ?: false, ctx.completion(result) + ) + } + "setGoHomeHeight" -> guard(controller, result) { + it.setGoHomeHeightInMeters((call.argument("value") ?: 100), ctx.completion(result)) + } + "setNoviceMode" -> guard(controller, result) { + it.setNoviceModeEnabled(call.argument("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("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)) + } +} diff --git a/Fly App/android/app/src/main/kotlin/com/dji/flutter/dji_msdk_sample/GimbalBridge.kt b/Fly App/android/app/src/main/kotlin/com/dji/flutter/dji_msdk_sample/GimbalBridge.kt new file mode 100644 index 0000000..f99d6c2 --- /dev/null +++ b/Fly App/android/app/src/main/kotlin/com/dji/flutter/dji_msdk_sample/GimbalBridge.kt @@ -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("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, + ) + ) + } + } +} diff --git a/Fly App/android/app/src/main/kotlin/com/dji/flutter/dji_msdk_sample/MediaBridge.kt b/Fly App/android/app/src/main/kotlin/com/dji/flutter/dji_msdk_sample/MediaBridge.kt new file mode 100644 index 0000000..344916e --- /dev/null +++ b/Fly App/android/app/src/main/kotlin/com/dji/flutter/dji_msdk_sample/MediaBridge.kt @@ -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 = 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("index") ?: -1, result) + "downloadMedia" -> download(call.argument("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 { + 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 { + 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 { + 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 { + 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) } + } + } + }) + } +} diff --git a/Fly App/android/app/src/main/kotlin/com/dji/flutter/dji_msdk_sample/MissionBridge.kt b/Fly App/android/app/src/main/kotlin/com/dji/flutter/dji_msdk_sample/MissionBridge.kt new file mode 100644 index 0000000..420f11e --- /dev/null +++ b/Fly App/android/app/src/main/kotlin/com/dji/flutter/dji_msdk_sample/MissionBridge.kt @@ -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>>("points") ?: emptyList() + if (raw.size < 2) { + ctx.fail(result, "A route needs at least 2 waypoints") + return + } + val speed = (call.argument("speed") ?: 8.0).toFloat() + val finish = try { + WaypointMissionFinishedAction.valueOf(call.argument("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("x") ?: 0.4).toFloat() + val y = (call.argument("y") ?: 0.35).toFloat() + val w = (call.argument("w") ?: 0.2).toFloat() + val h = (call.argument("h") ?: 0.3).toFloat() + val mode = try { + ActiveTrackMode.valueOf(call.argument("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("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)) + } +} diff --git a/Fly App/lib/dji_service.dart b/Fly App/lib/dji_service.dart index c368269..fa49fa5 100644 --- a/Fly App/lib/dji_service.dart +++ b/Fly App/lib/dji_service.dart @@ -2,38 +2,150 @@ import 'package:flutter/services.dart'; /// 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 /// * 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 { static const MethodChannel _methods = MethodChannel('dji_msdk/methods'); 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> events() { return _events .receiveBroadcastStream() .map((dynamic e) => Map.from(e as Map)); } - Future getSdkVersion() async { - return await _methods.invokeMethod('getSdkVersion') ?? 'unknown'; - } + // ── SDK / registration / connection ───────────────────────────────────────── + Future getSdkVersion() async => + await _methods.invokeMethod('getSdkVersion') ?? 'unknown'; - /// Kicks off DJI app registration (requires a valid App Key + internet). Future registerApp() => _methods.invokeMethod('registerApp'); - /// Starts scanning for a connected product (USB remote controller / Wi-Fi). - Future startConnection() async { - return await _methods.invokeMethod('startConnection') ?? false; - } + Future startConnection() async => + await _methods.invokeMethod('startConnection') ?? false; - Future stopConnection() => - _methods.invokeMethod('stopConnection'); + Future stopConnection() => _methods.invokeMethod('stopConnection'); Future> getProductInfo() async { final dynamic info = await _methods.invokeMethod('getProductInfo'); return Map.from(info as Map); } + + // ── Flight controller commands ────────────────────────────────────────────── + Future takeOff() => _methods.invokeMethod('takeOff'); + Future land() => _methods.invokeMethod('land'); + Future confirmLanding() => _methods.invokeMethod('confirmLanding'); + Future cancelLanding() => _methods.invokeMethod('cancelLanding'); + Future startGoHome() => _methods.invokeMethod('startGoHome'); + Future cancelGoHome() => _methods.invokeMethod('cancelGoHome'); + Future setHomeToCurrent() => _methods.invokeMethod('setHomeToCurrent'); + + // ── Flight settings (Safety / Control) ────────────────────────────────────── + Future setMaxFlightHeight(int m) => + _methods.invokeMethod('setMaxFlightHeight', {'value': m}); + Future setMaxFlightRadius(int m) => + _methods.invokeMethod('setMaxFlightRadius', {'value': m}); + Future setMaxRadiusEnabled(bool on) => + _methods.invokeMethod('setMaxRadiusEnabled', {'value': on}); + Future setGoHomeHeight(int m) => + _methods.invokeMethod('setGoHomeHeight', {'value': m}); + Future setNoviceMode(bool on) => + _methods.invokeMethod('setNoviceMode', {'value': on}); + Future setObstacleAvoidance(bool on) => + _methods.invokeMethod('setObstacleAvoidance', {'value': on}); + + // ── Camera ────────────────────────────────────────────────────────────────── + /// mode: `photo` | `video` | `mediaDownload` | `playback` + Future setCameraMode(String mode) => + _methods.invokeMethod('setCameraMode', {'mode': mode}); + Future startShootPhoto() => _methods.invokeMethod('startShootPhoto'); + Future stopShootPhoto() => _methods.invokeMethod('stopShootPhoto'); + Future startRecordVideo() => _methods.invokeMethod('startRecordVideo'); + Future stopRecordVideo() => _methods.invokeMethod('stopRecordVideo'); + + /// mode: SINGLE | HDR | BURST | AEB | INTERVAL | PANORAMA + Future setShootPhotoMode(String mode) => + _methods.invokeMethod('setShootPhotoMode', {'mode': mode}); + + /// program: `auto` (PROGRAM) | `pro` (MANUAL) + Future setExposureProgram(String program) => + _methods.invokeMethod('setExposureProgram', {'program': program}); + Future setISO(String value) => + _methods.invokeMethod('setISO', {'value': value}); + Future setShutterSpeed(String value) => + _methods.invokeMethod('setShutterSpeed', {'value': value}); + Future setAperture(String value) => + _methods.invokeMethod('setAperture', {'value': value}); + Future setEV(String value) => + _methods.invokeMethod('setEV', {'value': value}); + Future setWhiteBalance(String value) => + _methods.invokeMethod('setWhiteBalance', {'value': value}); + Future setHistogramEnabled(bool on) => + _methods.invokeMethod('setHistogramEnabled', {'value': on}); + + // ── Gimbal ────────────────────────────────────────────────────────────────── + Future rotateGimbalPitch(double deg) => + _methods.invokeMethod('rotateGimbalPitch', {'pitch': deg}); + Future resetGimbal() => _methods.invokeMethod('resetGimbal'); + + // ── Missions ──────────────────────────────────────────────────────────────── + /// points: [{lat, lon, altitude}], finishAction: NO_ACTION|GO_HOME|AUTO_LAND|GO_FIRST_WAYPOINT + Future uploadWaypointMission( + List> points, { + double speed = 8, + String finishAction = 'NO_ACTION', + }) => + _methods.invokeMethod('uploadWaypointMission', { + 'points': points, + 'speed': speed, + 'finishAction': finishAction, + }); + Future startWaypointMission() => _methods.invokeMethod('startWaypointMission'); + Future stopWaypointMission() => _methods.invokeMethod('stopWaypointMission'); + Future pauseWaypointMission() => _methods.invokeMethod('pauseWaypointMission'); + Future resumeWaypointMission() => _methods.invokeMethod('resumeWaypointMission'); + + /// Rect is normalized 0..1 in the live view. mode: TRACE|PROFILE|SPOTLIGHT|QUICK_SHOT + Future startActiveTrack( + double x, + double y, + double w, + double h, { + String mode = 'TRACE', + }) => + _methods.invokeMethod('startActiveTrack', { + 'x': x, + 'y': y, + 'w': w, + 'h': h, + 'mode': mode, + }); + Future stopActiveTrack() => _methods.invokeMethod('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 startQuickShot(String name) => + _methods.invokeMethod('startQuickShot', {'name': name}); + + // ── Media (MediaManager) ──────────────────────────────────────────────────── + Future refreshMediaList() => _methods.invokeMethod('refreshMediaList'); + Future fetchThumbnail(int index) => + _methods.invokeMethod('fetchThumbnail', {'index': index}); + Future downloadMedia(int index) => + _methods.invokeMethod('downloadMedia', {'index': index}); + Future deleteMedia(int index) => + _methods.invokeMethod('deleteMedia', {'index': index}); + + // ── DJI account (optional) ────────────────────────────────────────────────── + Future djiLogin() => _methods.invokeMethod('djiLogin'); + Future djiLogout() => _methods.invokeMethod('djiLogout'); + Future refreshDjiAccountState() => _methods.invokeMethod('getDjiAccountState'); } diff --git a/Fly App/lib/flight_model.dart b/Fly App/lib/flight_model.dart index 9c94290..78b34b2 100644 --- a/Fly App/lib/flight_model.dart +++ b/Fly App/lib/flight_model.dart @@ -4,29 +4,144 @@ import 'uploader.dart'; enum RegistrationState { idle, registering, success, failed } -/// Live aircraft/session state, shared by the Go Fly launch screen and the -/// Flight Control overlay. [_HomePageState] owns the DJI/uploader plumbing and -/// pushes updates here; the screens observe it via [AnimatedBuilder]. +/// Camera capture family, mirrors the right-rail Photo/Video/Pano switch. +enum CaptureMode { photo, video, pano } + +/// 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 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 { + // ── SDK / registration / connection ──────────────────────────────────────── String sdkVersion = '…'; RegistrationState registration = RegistrationState.idle; String? registrationError; - bool connected = false; String? model; + String? firmwareVersion; + // ── Flight controller telemetry ──────────────────────────────────────────── int? satellites; + int? gpsSignalLevel; // 0..5 bool? isFlying; + bool? motorsOn; String? flightMode; - double? altitude; + double? altitude; // m, relative to home double? latitude; double? longitude; - // Phone's own GPS (independent of the drone's fix above); streamed to the - // server as a location fallback for the Web App's automatic bounding box. + double? homeLatitude; + 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? 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? 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 media = []; + 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; bool get registered => registration == RegistrationState.success; diff --git a/Fly App/lib/main.dart b/Fly App/lib/main.dart index 66969c9..8b2790d 100644 --- a/Fly App/lib/main.dart +++ b/Fly App/lib/main.dart @@ -9,9 +9,13 @@ import 'flight_model.dart'; import 'login_page.dart'; import 'pb_auth.dart'; import 'theme.dart'; +import 'ui/academy_page.dart'; import 'ui/album_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'; Future main() async { @@ -148,6 +152,7 @@ class _HomePageState extends State { case 'connection': _model.connected = event['connected'] as bool? ?? false; _model.model = event['model'] as String?; + _model.firmwareVersion = event['firmware'] as String?; if (!_model.connected) _clearTelemetry(); _model.bump(); break; @@ -156,11 +161,20 @@ class _HomePageState extends State { // repopulates values _clearTelemetry() just wiped, leaving stale readings. if (!_model.connected) break; _model.satellites = event['satelliteCount'] as int?; + _model.gpsSignalLevel = event['gpsSignalLevel'] as int?; _model.isFlying = event['isFlying'] as bool?; + _model.motorsOn = event['areMotorsOn'] as bool?; _model.flightMode = event['flightMode'] as String?; _model.altitude = (event['altitude'] as num?)?.toDouble(); _model.latitude = (event['latitude'] 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(); break; case 'battery': @@ -168,19 +182,82 @@ class _HomePageState extends State { // last percentage (the reported "disconnected but still 42%" bug). if (!_model.connected) break; _model.batteryPercent = event['percent'] as int?; + _model.batteryVoltage = (event['voltage'] as num?)?.toDouble(); + _model.batteryTemperature = (event['temperature'] as num?)?.toDouble(); _model.bump(); 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 files = (event['files'] as List?) ?? const []; + _model.media = files + .map((dynamic e) => MediaItem.fromMap(Map.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() { _model.satellites = null; + _model.gpsSignalLevel = null; _model.isFlying = null; + _model.motorsOn = null; _model.flightMode = null; _model.altitude = null; _model.latitude = 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.batteryVoltage = null; + _model.batteryTemperature = null; + _model.isRecording = false; + _model.recordSeconds = 0; + _model.firmwareVersion = null; } Future _register() async { @@ -229,11 +306,19 @@ class _HomePageState extends State { } final Map tel = {'type': 'telemetry'}; 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.motorsOn != null) tel['areMotorsOn'] = _model.motorsOn; if (_model.flightMode != null) tel['flightMode'] = _model.flightMode; if (_model.altitude != null) tel['altitude'] = _model.altitude; if (_model.latitude != null) tel['latitude'] = _model.latitude; 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.phoneLongitude != null) tel['phoneLongitude'] = _model.phoneLongitude; if (tel.length > 1) events.add(tel); @@ -297,25 +382,72 @@ class _HomePageState extends State { void _goFly() { Navigator.of(context).push(MaterialPageRoute( - builder: (_) => FlightControlPage(model: _model), + builder: (_) => FlightControlPage(model: _model, dji: _dji), )); } void _openAlbum() { - Navigator.of(context).push(MaterialPageRoute(builder: (_) => const AlbumPage())); + Navigator.of(context).push(MaterialPageRoute( + 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 _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(builder: (_) => const AcademyPage())); + break; + case 'Routes': + Navigator.of(context).push(MaterialPageRoute(builder: (_) => const RoutesPage())); + break; + case 'Flight logs': + Navigator.of(context).push(MaterialPageRoute(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( + builder: (_) => ProfilePage( + model: _model, + dji: _dji, + onAppSettings: _openSettings, + onSignIn: _openLogin, + ), + )); + } // ── UI ─────────────────────────────────────────────────────────────────── @override Widget build(BuildContext context) { - return GoFlyPage( + return HomeScreen( model: _model, onGoFly: _goFly, - onOpenAlbum: _openAlbum, - onSettings: _openSettings, + onConnect: _connectFlow, + onSettings: _openProfile, onTile: _onTile, ); } diff --git a/Fly App/lib/ui/academy_page.dart b/Fly App/lib/ui/academy_page.dart new file mode 100644 index 0000000..27e8238 --- /dev/null +++ b/Fly App/lib/ui/academy_page.dart @@ -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 _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: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 6, 20, 14), + child: Row(children: [ + 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(0xFF26406E), Color(0xFF0F1E3D)]), + ), + child: Stack(children: [ + const Positioned( + left: 16, bottom: 16, right: 64, + child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + 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: [ + 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: [ + 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), + ]), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/Fly App/lib/ui/album_page.dart b/Fly App/lib/ui/album_page.dart index baa4785..759bce6 100644 --- a/Fly App/lib/ui/album_page.dart +++ b/Fly App/lib/ui/album_page.dart @@ -1,12 +1,22 @@ +import 'dart:io'; + 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'; -/// Portrait media grid — mirrors the ui_kit/fly "Album" mockup. Media is -/// placeholder content (the app has no on-device gallery source yet); the -/// layout, filters and badges match the design. +/// Portrait media grid — mirrors the v2 "Album" mockup. Backed by the aircraft's +/// SD card through the SDK MediaManager: refreshes the list on open, lazily +/// fetches thumbnails, and downloads originals on tap. Falls back to an empty +/// state when no aircraft/media is present. 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 State createState() => _AlbumPageState(); @@ -15,14 +25,57 @@ class AlbumPage extends StatefulWidget { class _AlbumPageState extends State { static const List _filters = ['All', 'Photos', 'Videos', 'Pano']; int _active = 0; + final Set _thumbRequested = {}; - // (isVideo, duration) — placeholder set from the mockup. - static const List<(bool, String?)> _media = <(bool, String?)>[ - (true, '0:24'), (false, null), (false, null), - (true, '1:12'), (false, null), (false, null), - (false, null), (true, '0:08'), (false, null), - (false, null), (true, '0:31'), (false, null), - ]; + DjiService get _dji => widget.dji; + FlightModel get _m => widget.model; + + @override + void initState() { + super.initState(); + _refresh(); + } + + Future _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 _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 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 Widget build(BuildContext context) { @@ -30,14 +83,17 @@ class _AlbumPageState extends State { return Scaffold( backgroundColor: s.bgApp, body: SafeArea( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _header(s), - _filterBar(s), - const SizedBox(height: 14), - Expanded(child: _grid(s)), - ], + child: AnimatedBuilder( + animation: _m, + builder: (BuildContext context, _) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _header(s), + _filterBar(s), + const SizedBox(height: 14), + Expanded(child: _body(s)), + ], + ), ), ), ); @@ -46,20 +102,16 @@ class _AlbumPageState extends State { Widget _header(PVScheme s) { return Padding( padding: const EdgeInsets.fromLTRB(12, 6, 20, 12), - child: Row( - children: [ - IconButton( - onPressed: () => Navigator.of(context).maybePop(), - 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), - ), - const Spacer(), - Text('${_media.length} items', style: TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: s.textTertiary)), - ], - ), + child: Row(children: [ + IconButton( + onPressed: () => Navigator.of(context).maybePop(), + icon: PVIcon('chevronLeft', size: 24, color: s.textSecondary), + ), + Text('Album', style: TextStyle(fontFamily: PV.fontSans, fontSize: 20, fontWeight: FontWeight.w700, letterSpacing: -0.4, color: s.textPrimary)), + const Spacer(), + IconButton(onPressed: _refresh, icon: PVIcon('search', size: 19, color: s.textSecondary)), + Text('${_m.media.length} items', style: TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: s.textTertiary)), + ]), ); } @@ -78,19 +130,9 @@ class _AlbumPageState extends State { child: Container( padding: const EdgeInsets.symmetric(horizontal: 12), alignment: Alignment.center, - decoration: BoxDecoration( - color: active ? s.accent : s.surfaceInset, - borderRadius: BorderRadius.circular(999), - ), - child: Text( - _filters[i], - style: TextStyle( - fontFamily: PV.fontSans, - fontSize: 12.5, - fontWeight: FontWeight.w600, - color: active ? Colors.white : s.textSecondary, - ), - ), + decoration: BoxDecoration(color: active ? s.accent : s.surfaceInset, borderRadius: BorderRadius.circular(999)), + 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 { ); } - Widget _grid(PVScheme s) { + Widget _body(PVScheme s) { + if (_m.mediaLoading) { + return Center(child: CircularProgressIndicator(color: s.accent)); + } + final List items = _visible; + if (items.isEmpty) { + return Center( + child: Column(mainAxisSize: MainAxisSize.min, children: [ + 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( padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - crossAxisSpacing: 6, - 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: [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: [ - 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)), - ), - ), - ], - ), - ), - ); - }, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3, crossAxisSpacing: 6, mainAxisSpacing: 6), + itemCount: items.length, + itemBuilder: (BuildContext context, int i) => _cell(s, items[i], i), ); } + + 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: [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: [ + 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 _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}'); + } + } } diff --git a/Fly App/lib/ui/camera_settings_page.dart b/Fly App/lib/ui/camera_settings_page.dart new file mode 100644 index 0000000..0008c0e --- /dev/null +++ b/Fly App/lib/ui/camera_settings_page.dart @@ -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 createState() => _CameraSettingsPageState(); +} + +class _CameraSettingsPageState extends State { + 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)> _dials = <(String, String, List)>[ + ('ISO', 'iso', ['AUTO', '100', '200', '400', '800', '1600', '3200', '6400']), + ('Shutter', 'shutter', ['1/2000', '1/1000', '1/500', '1/240', '1/120', '1/60', '1/30', '1/15', '1/8']), + ('Aperture', 'aperture', ['f/2.8', 'f/4', 'f/5.6', 'f/8', 'f/11']), + ('EV', 'ev', ['-2.0', '-1.3', '-0.7', '-0.3', '0.0', '+0.3', '+0.7', '+1.3', '+2.0']), + ('WB', 'wb', ['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 _run(String label, Future 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 _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 _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: [ + 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: [ + _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 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: [ + Row(mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ + 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: [ + 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: [ + 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; +} diff --git a/Fly App/lib/ui/capture_modes_page.dart b/Fly App/lib/ui/capture_modes_page.dart new file mode 100644 index 0000000..603c12a --- /dev/null +++ b/Fly App/lib/ui/capture_modes_page.dart @@ -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 createState() => _CaptureModesPageState(); +} + +class _CaptureModesPageState extends State { + 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 _run(String label, Future Function() action) async { + try { + await action(); + } catch (e) { + _snack('$label: ${e is PlatformException ? (e.message ?? e.code) : e}'); + } + } + + Future _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: [ + for (final (String label, String group, List<(String, String, String)> items) in _groups) ...[ + 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: [ + 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: [ + 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))), + ]), + ), + ); + } +} diff --git a/Fly App/lib/ui/flight_control_page.dart b/Fly App/lib/ui/flight_control_page.dart index 1c349b5..75155cf 100644 --- a/Fly App/lib/ui/flight_control_page.dart +++ b/Fly App/lib/ui/flight_control_page.dart @@ -3,18 +3,25 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import '../dji_service.dart'; import '../flight_model.dart'; import '../theme.dart'; +import 'camera_settings_page.dart'; +import 'capture_modes_page.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" mockup. Locks to landscape while shown and restores portrait on -/// exit. Camera-feed HUD is composited over a painted placeholder feed; real -/// telemetry (satellites, battery, altitude, mode) is bound where available. +/// Landscape live-flight overlay — mirrors the v2 "Main flight view" mockup. +/// Live camera feed behind a glass HUD; real telemetry bound throughout, and +/// the shutter / mode switch / gimbal slider / RTH / take-off controls issue +/// real SDK commands via [DjiService]. 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 DjiService dji; @override State createState() => _FlightControlPageState(); @@ -23,7 +30,12 @@ class FlightControlPage extends StatefulWidget { class _FlightControlPageState extends State { Timer? _recTimer; 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 void initState() { @@ -33,9 +45,6 @@ class _FlightControlPageState extends State { DeviceOrientation.landscapeRight, ]); SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); - _recTimer = Timer.periodic(const Duration(seconds: 1), (_) { - if (mounted) setState(() => _recSeconds++); - }); } @override @@ -46,88 +55,144 @@ class _FlightControlPageState extends State { super.dispose(); } - String get _recLabel { - final String mm = (_recSeconds ~/ 60).toString().padLeft(2, '0'); - final String ss = (_recSeconds % 60).toString().padLeft(2, '0'); - return '$mm:$ss'; + String _fmt(int s) => '${(s ~/ 60).toString().padLeft(2, '0')}:${(s % 60).toString().padLeft(2, '0')}'; + + void _snack(String msg) { + if (!mounted) return; + ScaffoldMessenger.of(context) + ..clearSnackBars() + ..showSnackBar(SnackBar(content: Text(msg))); + } + + Future _run(String label, Future 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 _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 _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 _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 _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 _confirm(String title, String body) async { + final bool? r = await showDialog( + 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: [ + 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( + builder: (_) => CaptureModesPage(model: _m, dji: _dji), + )); + } + + void _openCameraSettings() { + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => CameraSettingsPage(model: _m, dji: _dji), + )); + } + + void _openSettingsMenu() { + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => SettingsMenuPage(model: _m, dji: _dji), + )); + } + + void _openMap() { + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => MapPage(model: _m, dji: _dji), + )); } @override Widget build(BuildContext context) { - final FlightModel m = widget.model; return Scaffold( backgroundColor: const Color(0xFF0A1120), body: AnimatedBuilder( - animation: m, + animation: _m, builder: (BuildContext context, _) { return Stack( children: [ - // 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( - child: m.connected + child: _m.connected ? const DjiVideoView() : const CustomPaint(painter: _FeedPainter()), ), - - // Center reticle - const Center(child: Icon(Icons.add, size: 30, color: Color(0xB3FFFFFF))), - - // Top bar - Positioned( - top: 12, - left: 14, - right: 14, - child: _topBar(m), - ), - - // Left rail - Positioned( - left: 14, - top: 58, - child: Column( - children: [ - _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 + if (_grid) const Positioned.fill(child: IgnorePointer(child: CustomPaint(painter: _GridPainter()))), + const Center(child: PVIcon('crosshair', size: 30, stroke: 1.1, color: Color(0xB3FFFFFF))), + Positioned(top: 12, left: 14, right: 14, child: _topBar()), + Positioned(left: 14, top: 58, child: _leftRail()), + Positioned(left: 70, top: 58, bottom: 96, child: _gimbalSlider()), + Positioned(right: 16, top: 0, bottom: 0, child: Center(child: _cameraControls())), Positioned(left: 14, bottom: 12, child: _minimap()), - - // Bottom-center telemetry - Positioned( - bottom: 14, - left: 0, - right: 0, - child: Center(child: _telemetry(m)), - ), - - // RTH button + Positioned(bottom: 14, left: 0, right: 0, child: Center(child: _telemetry())), Positioned(right: 92, bottom: 20, child: _rthButton()), + Positioned(right: 92, bottom: 72, child: _takeoffButton()), ], ); }, @@ -136,54 +201,64 @@ class _FlightControlPageState extends State { } // ── Top bar ────────────────────────────────────────────────────────────── - Widget _topBar(FlightModel m) { - return Row( - children: [ - GestureDetector( - onTap: () => Navigator.of(context).maybePop(), - child: _pill(child: const Icon(Icons.chevron_left, size: 16, color: Glass.ink)), - ), - const SizedBox(width: 8), - Container( - height: 26, - padding: const EdgeInsets.symmetric(horizontal: 11), - decoration: BoxDecoration(color: Glass.accent, borderRadius: BorderRadius.circular(8)), - alignment: Alignment.center, - child: Text( - (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: [ - const Icon(Icons.satellite_alt, size: 14, color: Glass.sat), - const SizedBox(width: 4), - _mono(m.satellites?.toString() ?? '0'), - ])), - const SizedBox(width: 8), - _pill(child: Row(mainAxisSize: MainAxisSize.min, children: const [ - Icon(Icons.sensors, size: 14, color: Glass.ink), - SizedBox(width: 4), - Text('HD', style: TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: Glass.ink)), - ])), - const Spacer(), - _pill(child: Row(mainAxisSize: MainAxisSize.min, children: [ - _mono('REC'), - const SizedBox(width: 6), - Container(width: 7, height: 7, decoration: const BoxDecoration(color: Glass.rec, shape: BoxShape.circle)), - const SizedBox(width: 6), - _mono(_recLabel), - ])), - const SizedBox(width: 8), - _pill(child: Row(mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.battery_full, size: 16, color: Glass.sat), - const SizedBox(width: 4), - _mono(m.batteryPercent == null ? '—' : '${m.batteryPercent}%'), - ])), - const SizedBox(width: 8), - _pill(child: const Icon(Icons.settings, size: 16, color: Glass.ink)), - ], - ); + Widget _topBar() { + final String mode = (_m.flightMode != null && _m.flightMode!.isNotEmpty) ? _m.flightMode! : 'N'; + return Row(children: [ + GestureDetector( + onTap: () => Navigator.of(context).maybePop(), + child: _pill(child: const PVIcon('chevronLeft', size: 16, color: Glass.ink)), + ), + const SizedBox(width: 8), + Container( + height: 26, + padding: const EdgeInsets.symmetric(horizontal: 11), + decoration: BoxDecoration(color: Glass.accent, borderRadius: BorderRadius.circular(8)), + alignment: Alignment.center, + child: Text(mode, 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: [ + const PVIcon('satellite', size: 14, color: Glass.sat), + const SizedBox(width: 4), + _mono(_m.satellites?.toString() ?? '0'), + ])), + const SizedBox(width: 8), + _pill(child: const PVIcon('obstacle', size: 13, color: Glass.sat)), + const SizedBox(width: 8), + _pill(child: Row(mainAxisSize: MainAxisSize.min, children: [ + const PVIcon('radio', size: 14, color: Glass.ink), + const SizedBox(width: 4), + _mono('HD'), + ])), + const Spacer(), + _pill(child: Row(mainAxisSize: MainAxisSize.min, children: [ + _mono('REC'), + const SizedBox(width: 6), + Container(width: 7, height: 7, decoration: BoxDecoration(color: _m.isRecording ? Glass.rec : const Color(0x66D64545), shape: BoxShape.circle)), + const SizedBox(width: 6), + _mono(_fmt(_m.isRecording ? _recSeconds : 0)), + ])), + const SizedBox(width: 8), + _pill(child: Row(mainAxisSize: MainAxisSize.min, children: [ + const PVIcon('battery', size: 16, color: Glass.sat), + const SizedBox(width: 4), + _mono(_m.batteryPercent == null ? '—' : '${_m.batteryPercent}%'), + ])), + const SizedBox(width: 8), + GestureDetector(onTap: _openSettingsMenu, child: _pill(child: const PVIcon('more', size: 16, color: Glass.ink))), + ]); + } + + Widget _leftRail() { + return Column(children: [ + _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 ──────────────────────────────────────────────── @@ -199,167 +274,168 @@ class _FlightControlPageState extends State { Widget _mono(String t) => Text(t, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: Glass.ink)); - Widget _sideBtn(IconData icon, {bool active = false}) { - return Container( - width: 42, - height: 42, - decoration: BoxDecoration( - color: active ? Glass.accent : Glass.pill, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Glass.hairline), + Widget _sideBtn(String icon, {bool active = false, 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), ), - child: Icon(icon, size: 20, color: Glass.ink), ); } Widget _gimbalSlider() { - return SizedBox( - width: 16, - child: Stack( - alignment: Alignment.topCenter, - children: [ - Container(width: 6, decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(6))), - const Align( - alignment: Alignment(0, -0.24), - child: _Thumb(), - ), - ], - ), - ); + return LayoutBuilder(builder: (BuildContext context, BoxConstraints c) { + final double h = c.maxHeight; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onVerticalDragUpdate: (DragUpdateDetails d) => _onGimbalDrag(d.localPosition.dy / h), + onTapDown: (TapDownDetails d) => _onGimbalDrag(d.localPosition.dy / h), + child: SizedBox( + width: 16, + child: Stack(children: [ + 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() { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 46, - height: 46, + return Column(mainAxisSize: MainAxisSize.min, children: [ + GestureDetector( + onTap: _openCameraSettings, + child: Container( + width: 46, height: 46, decoration: BoxDecoration( borderRadius: BorderRadius.circular(10), border: Border.all(color: const Color(0x99FFFFFF), width: 2), gradient: const LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xFF2A4E86), Color(0xFF12201A)]), ), ), - const SizedBox(height: 14), - // Shutter - Container( - width: 62, - height: 62, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all(color: const Color(0xD9FFFFFF), width: 4), - ), + ), + const SizedBox(height: 14), + GestureDetector( + onTap: _toggleShutter, + child: Container( + width: 62, height: 62, + decoration: BoxDecoration(shape: BoxShape.circle, border: Border.all(color: const Color(0xD9FFFFFF), width: 4)), child: Center( - child: Container( - width: 26, - height: 26, - decoration: BoxDecoration(color: Glass.rec, borderRadius: BorderRadius.circular(7)), - ), + child: _m.captureMode == CaptureMode.video && _m.isRecording + ? Container(width: 24, height: 24, decoration: BoxDecoration(color: Glass.rec, borderRadius: BorderRadius.circular(5))) + : Container(width: 46, height: 46, decoration: BoxDecoration(color: _m.captureMode == CaptureMode.video ? Glass.rec : Colors.white, shape: BoxShape.circle)), ), ), - const SizedBox(height: 14), - // Mode switch - Container( - padding: const EdgeInsets.all(3), - decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(10)), - child: Column( - children: [ - _modeBtn(Icons.photo_outlined, 'Photo'), - _modeBtn(Icons.videocam_outlined, 'Video'), - _modeBtn(Icons.panorama_outlined, 'Pano'), - ], - ), - ), - ], - ); + ), + const SizedBox(height: 14), + Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(10)), + child: Column(children: [ + _modeBtn('image', CaptureMode.photo), + _modeBtn('video', CaptureMode.video), + _modeBtn('film', CaptureMode.pano), + ]), + ), + ]); } - Widget _modeBtn(IconData icon, String mode) { - final bool active = _mode == mode; + Widget _modeBtn(String icon, CaptureMode mode) { + final bool active = _m.captureMode == mode; return GestureDetector( - onTap: () => setState(() => _mode = mode), + onTap: () => _setCaptureMode(mode), child: Container( - width: 40, - height: 30, + width: 40, height: 30, margin: const EdgeInsets.symmetric(vertical: 1), - decoration: BoxDecoration( - color: active ? Glass.accent : Colors.transparent, - borderRadius: BorderRadius.circular(7), - ), - child: Icon(icon, size: 17, color: Glass.ink), + decoration: BoxDecoration(color: active ? Glass.accent : Colors.transparent, borderRadius: BorderRadius.circular(7)), + alignment: Alignment.center, + child: PVIcon(icon, size: 17, stroke: 1.8, color: Glass.ink), ), ); } Widget _minimap() { - return Container( - width: 148, - height: 78, - decoration: BoxDecoration( - color: Glass.pillStrong, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Glass.hairline), - ), - child: Stack( - children: [ + final String rth = _m.homeDistance == null ? 'RTH —' : 'RTH ${_m.homeDistance!.toStringAsFixed(0)}m'; + return GestureDetector( + onTap: _openMap, + child: Container( + width: 148, height: 78, + decoration: BoxDecoration(color: Glass.pillStrong, borderRadius: BorderRadius.circular(12), border: Border.all(color: Glass.hairline)), + child: Stack(children: [ const Positioned.fill(child: CustomPaint(painter: _MinimapPainter())), - const Positioned( - top: 6, - left: 8, - child: Row(children: [ - 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)), - ]), - ), - ], + Positioned(top: 6, left: 8, child: Row(children: [ + const PVIcon('home', size: 12, color: Glass.ink), + const SizedBox(width: 5), + Text(rth, style: const 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)>[ - ('H', m.altitude == null ? '—' : m.altitude!.toStringAsFixed(1), 'm'), - ('D', '—', 'm'), - ('H.S', '—', 'm/s'), - ('V.S', '—', 'm/s'), + ('H', f(_m.altitude), 'm'), + ('D', f(_m.homeDistance, d: 0), 'm'), + ('H.S', f(_m.horizontalSpeed), 'm/s'), + ('V.S', f(_m.verticalSpeed), 'm/s'), ]; return Container( padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 8), decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(12)), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - for (int i = 0; i < fields.length; i++) ...[ - if (i > 0) const SizedBox(width: 20), - Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text(fields[i].$1, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 10, letterSpacing: 0.8, color: Color(0x99EAF0FA))), - const SizedBox(height: 2), - Text.rich(TextSpan(children: [ - 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))), - ])), - ], - ), - ], + child: Row(mainAxisSize: MainAxisSize.min, children: [ + for (int i = 0; i < fields.length; i++) ...[ + if (i > 0) const SizedBox(width: 20), + Column(mainAxisSize: MainAxisSize.min, children: [ + Text(fields[i].$1, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 10, letterSpacing: 0.8, color: Color(0x99EAF0FA))), + const SizedBox(height: 2), + Text.rich(TextSpan(children: [ + 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() { - return Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: Glass.pillStrong, - shape: BoxShape.circle, - border: Border.all(color: const Color(0x2EFFFFFF)), + return GestureDetector( + onTap: _rth, + child: Container( + width: 44, height: 44, + decoration: BoxDecoration(color: Glass.pillStrong, shape: BoxShape.circle, border: Border.all(color: const Color(0x2EFFFFFF))), + alignment: Alignment.center, + 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 Widget build(BuildContext context) { return Container( - width: 16, - height: 16, + width: 16, height: 16, decoration: const BoxDecoration( - color: Glass.ink, - shape: BoxShape.circle, + color: Glass.ink, shape: BoxShape.circle, 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, /// distant buildings, and a yellow tracked-subject bracket. class _FeedPainter extends CustomPainter { @@ -390,7 +483,6 @@ class _FeedPainter extends CustomPainter { final double w = size.width, h = size.height; final double horizon = h * 0.52; - // Sky → ground gradient. final Rect full = Offset.zero & size; final Paint sky = Paint() ..shader = const LinearGradient( @@ -401,7 +493,6 @@ class _FeedPainter extends CustomPainter { ).createShader(full); canvas.drawRect(full, sky); - // Horizon haze. final Paint haze = Paint() ..shader = LinearGradient( begin: Alignment.topCenter, @@ -410,7 +501,6 @@ class _FeedPainter extends CustomPainter { ).createShader(Rect.fromLTWH(0, horizon - 30, w, 60)); canvas.drawRect(Rect.fromLTWH(0, horizon - 30, w, 60), haze); - // Distant buildings just under the horizon. 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); building(0.10, -40, 46, 40); @@ -418,7 +508,6 @@ class _FeedPainter extends CustomPainter { building(0.74, -46, 54, 46); building(0.83, -34, 34, 34); - // Perspective ground grid. final Paint grid = Paint() ..color = const Color(0x297FE0B0) ..strokeWidth = 1; @@ -433,15 +522,13 @@ class _FeedPainter extends CustomPainter { canvas.drawLine(Offset(vx + k * 10, horizon), Offset(bx, h), grid); } - // Tracked-subject bracket, centered. final Paint subj = Paint() ..color = Glass.subject ..style = PaintingStyle.stroke ..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); 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.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); diff --git a/Fly App/lib/ui/flight_logs_page.dart b/Fly App/lib/ui/flight_logs_page.dart new file mode 100644 index 0000000..3bfe534 --- /dev/null +++ b/Fly App/lib/ui/flight_logs_page.dart @@ -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.', + ); +} diff --git a/Fly App/lib/ui/flight_overlay_scaffold.dart b/Fly App/lib/ui/flight_overlay_scaffold.dart new file mode 100644 index 0000000..05331ec --- /dev/null +++ b/Fly App/lib/ui/flight_overlay_scaffold.dart @@ -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 createState() => _FlightOverlayScaffoldState(); +} + +class _FlightOverlayScaffoldState extends State { + @override + void initState() { + super.initState(); + SystemChrome.setPreferredOrientations([ + 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: [ + Padding( + padding: widget.padded ? EdgeInsets.zero : const EdgeInsets.fromLTRB(16, 12, 16, 8), + child: Row(children: [ + if (widget.leading != null) ...[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.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), + ], + ), + ), + ), + ); + } +} diff --git a/Fly App/lib/ui/go_fly_page.dart b/Fly App/lib/ui/go_fly_page.dart deleted file mode 100644 index 65d0025..0000000 --- a/Fly App/lib/ui/go_fly_page.dart +++ /dev/null @@ -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: [ - _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: [ - const PvBrandMark(size: 22), - const SizedBox(width: 8), - Text.rich( - TextSpan(children: [ - 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: [ - Row( - children: [ - 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: [ - 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: [ - 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: [ - _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: [ - 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 [ - 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: [ - Row(children: [ - Expanded(child: _tile(s, tiles[0])), - const SizedBox(width: 12), - Expanded(child: _tile(s, tiles[1])), - ]), - const SizedBox(height: 12), - Row(children: [ - 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: [ - 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), - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/Fly App/lib/ui/home_page.dart b/Fly App/lib/ui/home_page.dart new file mode 100644 index 0000000..b2124b2 --- /dev/null +++ b/Fly App/lib/ui/home_page.dart @@ -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: [ + _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: [ + const PvBrandMark(size: 22), + const SizedBox(width: 8), + Text.rich(TextSpan(children: [ + 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: [ + Row(children: [ + 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: [ + 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: [ + 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: [ + _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: [ + 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: [ + 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: [ + 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 [ + 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: [ + Row(children: [ + Expanded(child: _tile(s, tiles[0])), const SizedBox(width: 12), Expanded(child: _tile(s, tiles[1])), + ]), + const SizedBox(height: 12), + Row(children: [ + 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: [ + 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))), + ]), + ), + ), + ); + } +} diff --git a/Fly App/lib/ui/map_page.dart b/Fly App/lib/ui/map_page.dart new file mode 100644 index 0000000..c15fa57 --- /dev/null +++ b/Fly App/lib/ui/map_page.dart @@ -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 createState() => _MapPageState(); +} + +class _MapPageState extends State { + final MapController _map = MapController(); + final List _waypoints = []; + 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.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 _runRoute() async { + if (_waypoints.length < 2) { + _snack('Drop at least 2 waypoints first'); + return; + } + final List> points = _waypoints + .map((LatLng p) => {'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: [ + FlutterMap( + mapController: _map, + options: MapOptions(initialCenter: center, initialZoom: 16, onTap: _onTap), + children: [ + TileLayer( + urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: 'com.dji.flutter.dji_msdk_sample', + ), + if (_waypoints.length >= 2) + PolylineLayer(polylines: >[ + Polyline(points: _waypoints, strokeWidth: 3, color: const Color(0xFF5B93F5)), + ]), + MarkerLayer(markers: _markers()), + ], + ), + SafeArea( + child: Stack(children: [ + Positioned(top: 12, left: 14, child: _back()), + Positioned(left: 14, top: 58, child: _toolRail()), + Positioned(right: 16, top: 58, child: _routePanel()), + ]), + ), + ]), + ); + } + + List _markers() { + final List m = []; + 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: [ + _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: [ + 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.generate(_waypoints.length, (int i) => Padding( + padding: const EdgeInsets.symmetric(vertical: 5), + child: Row(children: [ + 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 [ + 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 onChanged) { + return Row(children: [ + 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)), + ); + } +} diff --git a/Fly App/lib/ui/profile_page.dart b/Fly App/lib/ui/profile_page.dart new file mode 100644 index 0000000..257095b --- /dev/null +++ b/Fly App/lib/ui/profile_page.dart @@ -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 createState() => _ProfilePageState(); +} + +class _ProfilePageState extends State { + StreamSubscription? _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 _djiLogin() async { + try { + await _dji.djiLogin(); + _snack('DJI account linked'); + } catch (e) { + _snack('DJI login: ${e is PlatformException ? (e.message ?? e.code) : e}'); + } + } + + Future _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: [ + _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: [ + 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: [ + 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: [ + 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: [ + 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: [ + 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: [ + 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: [ + 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 _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 [ + 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: [ + 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(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'); + } + } +} diff --git a/Fly App/lib/ui/pv_icons.dart b/Fly App/lib/ui/pv_icons.dart new file mode 100644 index 0000000..45aa4f6 --- /dev/null +++ b/Fly App/lib/ui/pv_icons.dart @@ -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 `` component. Rendered through [PVIcon]. +class PvPaths { + PvPaths._(); + + static const Map d = { + // 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 `` 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 = + '' + ''; + 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)}'; + } +} diff --git a/Fly App/lib/ui/routes_page.dart b/Fly App/lib/ui/routes_page.dart new file mode 100644 index 0000000..8df85a9 --- /dev/null +++ b/Fly App/lib/ui/routes_page.dart @@ -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.', + ); +} diff --git a/Fly App/lib/ui/settings_menu_page.dart b/Fly App/lib/ui/settings_menu_page.dart new file mode 100644 index 0000000..b347c30 --- /dev/null +++ b/Fly App/lib/ui/settings_menu_page.dart @@ -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 createState() => _SettingsMenuPageState(); +} + +class _SettingsMenuPageState extends State { + 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 _run(String label, Future 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: [ + Row(children: [ + _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: [ + Padding( + padding: const EdgeInsets.only(left: 6, bottom: 10), + child: Row(children: const [ + 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: [ + 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 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: [ + 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 _safetyRows() => [ + _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', ['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 _controlRows() => [ + _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 _cameraRows() => [ + _infoRow('Exposure', _m.exposureProgram == ExposureProgram.pro ? 'Pro' : 'Auto'), + _infoRow('ISO', _m.iso ?? '—'), + _infoRow('Shutter', _m.shutter ?? '—'), + _infoRow('White Balance', _m.whiteBalance ?? 'Auto'), + ]; + + List _infoRows(List<(String, String)> items) => + [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: [ + 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: [ + 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 options, ValueChanged onChanged) => _rowShell( + child: InkWell( + onTap: () { + final int i = (options.indexOf(value) + 1) % options.length; + onChanged(options[i]); + }, + child: Row(children: [ + 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 onChanged) => _rowShell( + child: Row(children: [ + 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 _editNumber(String title, int initial, int min, int max, int step, ValueChanged onSet) async { + int value = initial; + await showDialog( + 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: [ + 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: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancel')), + FilledButton(onPressed: () { + Navigator.pop(ctx); + onSet(value); + }, child: const Text('Set')), + ], + ), + ), + ); + } +} diff --git a/Fly App/lib/ui/simple_list_page.dart b/Fly App/lib/ui/simple_list_page.dart new file mode 100644 index 0000000..e6943a2 --- /dev/null +++ b/Fly App/lib/ui/simple_list_page.dart @@ -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: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 6, 20, 12), + child: Row(children: [ + 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: [ + 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)), + ]), + ), + ), + ], + ), + ), + ); + } +} diff --git a/Fly App/pubspec.lock b/Fly App/pubspec.lock index d377e27..e237566 100644 --- a/Fly App/pubspec.lock +++ b/Fly App/pubspec.lock @@ -89,6 +89,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -142,6 +150,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -150,6 +166,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct dev" description: flutter @@ -208,6 +232,22 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -224,6 +264,22 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -232,6 +288,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -264,6 +328,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.1.1" + lists: + dependency: transitive + description: + name: lists + sha256: "4ca5c19ae4350de036a7e996cdd1ee39c93ac0a2b840f4915459b7d0a7d4ab27" + url: "https://pub.dev" + source: hosted + version: "1.0.1" local_auth: dependency: "direct main" description: @@ -304,6 +376,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.11" + logger: + dependency: transitive + description: + name: logger + sha256: "25aee487596a6257655a1e091ec2ae66bc30e7af663592cc3a27e6591e05035c" + url: "https://pub.dev" + source: hosted + version: "2.7.0" matcher: dependency: transitive description: @@ -328,6 +408,22 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -336,6 +432,38 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -384,6 +512,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + polylabel: + dependency: transitive + description: + name: polylabel + sha256: "41b9099afb2aa6c1730bdd8a0fab1400d287694ec7615dd8516935fa3144214b" + url: "https://pub.dev" + source: hosted + version: "1.0.1" posix: dependency: transitive description: @@ -392,6 +528,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.5.0" + proj4dart: + dependency: transitive + description: + name: proj4dart + sha256: c8a659ac9b6864aa47c171e78d41bbe6f5e1d7bd790a5814249e6b68bc44324e + url: "https://pub.dev" + source: hosted + version: "2.1.0" shared_preferences: dependency: "direct main" description: @@ -509,6 +653,78 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -517,6 +733,30 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -541,6 +781,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -566,5 +814,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.11.0 <4.0.0" - flutter: ">=3.38.0" + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" diff --git a/Fly App/pubspec.yaml b/Fly App/pubspec.yaml index 240987a..318372f 100644 --- a/Fly App/pubspec.yaml +++ b/Fly App/pubspec.yaml @@ -47,6 +47,20 @@ dependencies: # App's automatic bounding box when the drone has no fix. 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: flutter_test: sdk: flutter