Compare commits
7 Commits
main
...
linfeng/de
| Author | SHA1 | Date | |
|---|---|---|---|
| 88d8dfda04 | |||
| d39d85cd99 | |||
| 25ac9c4c35 | |||
| a3a02e623f | |||
| cf1c2d7d0e | |||
| 13cb3bfd7b | |||
| bcd2162cd7 |
@@ -4,7 +4,7 @@ plugins {
|
|||||||
id("dev.flutter.flutter-gradle-plugin")
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
}
|
}
|
||||||
|
|
||||||
val appPackageName = "com.qxy.dronex"
|
val appPackageName = "com.dronex.rec"
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = appPackageName
|
namespace = appPackageName
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
package="com.qxy.dronex">
|
package="com.dronex.rec">
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.qxy.dronex
|
package com.dronex.rec
|
||||||
|
|
||||||
object AppConstants {
|
object AppConstants {
|
||||||
const val PACKAGE_NAME = "com.qxy.dronex"
|
const val PACKAGE_NAME = "com.dronex.rec"
|
||||||
const val PLATFORM_INFO_CHANNEL = "$PACKAGE_NAME/platform_info"
|
const val PLATFORM_INFO_CHANNEL = "$PACKAGE_NAME/platform_info"
|
||||||
const val RECORDING_METHOD_CHANNEL = "$PACKAGE_NAME/recording"
|
const val RECORDING_METHOD_CHANNEL = "$PACKAGE_NAME/recording"
|
||||||
const val RECORDING_EVENT_CHANNEL = "$PACKAGE_NAME/recording_events"
|
const val RECORDING_EVENT_CHANNEL = "$PACKAGE_NAME/recording_events"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.qxy.dronex
|
package com.dronex.rec
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.ApplicationInfo
|
import android.content.pm.ApplicationInfo
|
||||||
@@ -7,8 +7,8 @@ import android.os.Build
|
|||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.os.StatFs
|
import android.os.StatFs
|
||||||
import androidx.camera.view.PreviewView
|
import androidx.camera.view.PreviewView
|
||||||
import com.qxy.dronex.recording.RecordingPlatformHandler
|
import com.dronex.rec.recording.RecordingPlatformHandler
|
||||||
import com.qxy.dronex.recording.RecordingPreviewFactory
|
import com.dronex.rec.recording.RecordingPreviewFactory
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.qxy.dronex.recording
|
package com.dronex.rec.recording
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.qxy.dronex.recording
|
package com.dronex.rec.recording
|
||||||
|
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -0,0 +1,570 @@
|
|||||||
|
package com.dronex.rec.recording
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.hardware.camera2.CameraCharacteristics
|
||||||
|
import android.hardware.camera2.CameraManager
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.camera.camera2.interop.Camera2CameraInfo
|
||||||
|
import androidx.camera.core.Camera
|
||||||
|
import androidx.camera.core.CameraSelector
|
||||||
|
import androidx.camera.core.Preview
|
||||||
|
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||||
|
import androidx.camera.video.Quality
|
||||||
|
import androidx.camera.video.QualitySelector
|
||||||
|
import androidx.camera.video.Recorder
|
||||||
|
import androidx.camera.video.Recording
|
||||||
|
import androidx.camera.video.VideoCapture
|
||||||
|
import androidx.camera.video.VideoRecordEvent
|
||||||
|
import androidx.camera.view.PreviewView
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import kotlin.math.atan
|
||||||
|
import java.util.concurrent.Executor
|
||||||
|
|
||||||
|
class RecordingCameraController(
|
||||||
|
private val appContext: Context,
|
||||||
|
) {
|
||||||
|
private val mainExecutor: Executor = ContextCompat.getMainExecutor(appContext)
|
||||||
|
|
||||||
|
private var cameraProvider: ProcessCameraProvider? = null
|
||||||
|
private var preview: Preview? = null
|
||||||
|
private var videoCapture: VideoCapture<Recorder>? = null
|
||||||
|
private var camera: Camera? = null
|
||||||
|
private var mainCameraId: String? = null
|
||||||
|
private var ultraWideCameraId: String? = null
|
||||||
|
private var ultraWideZoomRatio: Float = DEFAULT_ULTRA_WIDE_ZOOM_RATIO
|
||||||
|
private var currentLensMode: LensMode = LensMode.MAIN
|
||||||
|
private var activeRecording: Recording? = null
|
||||||
|
private var boundLifecycleOwner: LifecycleOwner? = null
|
||||||
|
private var currentZoomRatio: Float = 1f
|
||||||
|
|
||||||
|
var status: RecordingStatus = RecordingStatus(RecordingState.IDLE)
|
||||||
|
private set
|
||||||
|
|
||||||
|
var statusListener: ((RecordingStatus) -> Unit)? = null
|
||||||
|
|
||||||
|
private var recordingStartedAt: Long = 0L
|
||||||
|
private var latestOutputPath: String? = null
|
||||||
|
private var pendingStopCallback: ((String?) -> Unit)? = null
|
||||||
|
|
||||||
|
fun bindPreview(
|
||||||
|
lifecycleOwner: LifecycleOwner,
|
||||||
|
previewView: PreviewView,
|
||||||
|
onReady: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
val future = ProcessCameraProvider.getInstance(appContext)
|
||||||
|
future.addListener(
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
val provider = future.get()
|
||||||
|
cameraProvider = provider
|
||||||
|
boundLifecycleOwner = lifecycleOwner
|
||||||
|
|
||||||
|
preview =
|
||||||
|
Preview.Builder().build().also {
|
||||||
|
it.surfaceProvider = previewView.surfaceProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
val recorder =
|
||||||
|
Recorder.Builder()
|
||||||
|
.setQualitySelector(QualitySelector.from(Quality.HD))
|
||||||
|
.build()
|
||||||
|
videoCapture = VideoCapture.withOutput(recorder)
|
||||||
|
|
||||||
|
discoverBackCameras(provider)
|
||||||
|
bindUseCases(provider, lifecycleOwner, selectorForCurrentLensMode())
|
||||||
|
applyCurrentZoom()
|
||||||
|
|
||||||
|
updateStatus(RecordingStatus(RecordingState.PREVIEWING))
|
||||||
|
onReady(true)
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.e(TAG, "bindPreview failed", error)
|
||||||
|
updateStatus(
|
||||||
|
RecordingStatus(
|
||||||
|
RecordingState.ERROR,
|
||||||
|
message = error.message,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
onReady(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mainExecutor,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun rebindForRecording(
|
||||||
|
lifecycleOwner: LifecycleOwner,
|
||||||
|
previewView: PreviewView,
|
||||||
|
onReady: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
val provider = cameraProvider
|
||||||
|
if (provider == null) {
|
||||||
|
bindPreview(lifecycleOwner, previewView, onReady)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
boundLifecycleOwner === lifecycleOwner &&
|
||||||
|
preview != null &&
|
||||||
|
videoCapture != null
|
||||||
|
) {
|
||||||
|
onReady(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
boundLifecycleOwner = lifecycleOwner
|
||||||
|
bindUseCases(provider, lifecycleOwner, selectorForCurrentLensMode())
|
||||||
|
applyCurrentZoom()
|
||||||
|
onReady(true)
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.e(TAG, "rebindForRecording failed", error)
|
||||||
|
onReady(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startRecording(
|
||||||
|
withAudio: Boolean,
|
||||||
|
displayName: String?,
|
||||||
|
onStarted: (Boolean, String?) -> Unit,
|
||||||
|
) {
|
||||||
|
val capture = videoCapture
|
||||||
|
if (capture == null || boundLifecycleOwner == null) {
|
||||||
|
onStarted(false, "Camera not ready")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeRecording != null) {
|
||||||
|
onStarted(false, "Already recording")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val outputOptions =
|
||||||
|
RecordingOutputFactory.buildMediaStoreOutputOptions(
|
||||||
|
appContext,
|
||||||
|
displayName,
|
||||||
|
)
|
||||||
|
latestOutputPath = null
|
||||||
|
|
||||||
|
val pending =
|
||||||
|
capture.output.prepareRecording(appContext, outputOptions).apply {
|
||||||
|
if (withAudio) {
|
||||||
|
val granted =
|
||||||
|
ContextCompat.checkSelfPermission(
|
||||||
|
appContext,
|
||||||
|
android.Manifest.permission.RECORD_AUDIO,
|
||||||
|
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||||
|
if (granted) {
|
||||||
|
withAudioEnabled()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recordingStartedAt = System.currentTimeMillis()
|
||||||
|
updateStatus(
|
||||||
|
RecordingStatus(
|
||||||
|
RecordingState.RECORDING,
|
||||||
|
outputPath = latestOutputPath,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
activeRecording =
|
||||||
|
pending.start(mainExecutor) { event ->
|
||||||
|
when (event) {
|
||||||
|
is VideoRecordEvent.Start -> Unit
|
||||||
|
is VideoRecordEvent.Finalize -> {
|
||||||
|
activeRecording = null
|
||||||
|
if (event.hasError()) {
|
||||||
|
updateStatus(
|
||||||
|
RecordingStatus(
|
||||||
|
RecordingState.ERROR,
|
||||||
|
message = event.cause?.message
|
||||||
|
?: "Recording failed",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
latestOutputPath = event.outputResults.outputUri.toString()
|
||||||
|
updateStatus(
|
||||||
|
RecordingStatus(
|
||||||
|
RecordingState.PREVIEWING,
|
||||||
|
outputPath = latestOutputPath,
|
||||||
|
elapsedMillis =
|
||||||
|
System.currentTimeMillis() -
|
||||||
|
recordingStartedAt,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val stopCallback = pendingStopCallback
|
||||||
|
pendingStopCallback = null
|
||||||
|
stopCallback?.invoke(latestOutputPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onStarted(true, latestOutputPath ?: "recording")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopRecording(onStopped: (String?) -> Unit) {
|
||||||
|
val recording = activeRecording
|
||||||
|
if (recording == null) {
|
||||||
|
onStopped(latestOutputPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingStopCallback = onStopped
|
||||||
|
updateStatus(
|
||||||
|
RecordingStatus(
|
||||||
|
RecordingState.STOPPING,
|
||||||
|
outputPath = latestOutputPath,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
recording.stop()
|
||||||
|
activeRecording = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun zoomCapabilitiesMap(): Map<String, Any> {
|
||||||
|
val zoomState = camera?.cameraInfo?.zoomState?.value
|
||||||
|
val minZoom =
|
||||||
|
if (hasUltraWideCamera()) {
|
||||||
|
ultraWideZoomRatio
|
||||||
|
} else {
|
||||||
|
zoomState?.minZoomRatio ?: 1f
|
||||||
|
}
|
||||||
|
val maxZoom = zoomState?.maxZoomRatio ?: 3f
|
||||||
|
val zoom =
|
||||||
|
if (currentLensMode == LensMode.ULTRA_WIDE) {
|
||||||
|
ultraWideZoomRatio
|
||||||
|
} else {
|
||||||
|
(zoomState?.zoomRatio ?: currentZoomRatio).coerceIn(minZoom, maxZoom)
|
||||||
|
}
|
||||||
|
currentZoomRatio = zoom
|
||||||
|
return mapOf(
|
||||||
|
"zoomRatio" to zoom.toDouble(),
|
||||||
|
"minZoomRatio" to minZoom.toDouble(),
|
||||||
|
"maxZoomRatio" to maxZoom.toDouble(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setZoomRatio(
|
||||||
|
ratio: Double,
|
||||||
|
onComplete: (Boolean, Map<String, Any>, String?) -> Unit,
|
||||||
|
) {
|
||||||
|
val boundCamera = camera
|
||||||
|
if (boundCamera == null) {
|
||||||
|
val clamped =
|
||||||
|
if (ratio < 1.0 && hasUltraWideCamera()) {
|
||||||
|
ultraWideZoomRatio
|
||||||
|
} else {
|
||||||
|
ratio.toFloat().coerceAtLeast(1f)
|
||||||
|
}
|
||||||
|
currentZoomRatio = clamped
|
||||||
|
onComplete(true, zoomCapabilitiesMap(), null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ratio < 1.0 && hasUltraWideCamera()) {
|
||||||
|
switchToUltraWide(onComplete)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentLensMode == LensMode.ULTRA_WIDE) {
|
||||||
|
switchToMainAndZoom(ratio, onComplete)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val zoomState = boundCamera.cameraInfo.zoomState.value
|
||||||
|
val minZoom = zoomState?.minZoomRatio ?: 1f
|
||||||
|
val maxZoom = zoomState?.maxZoomRatio ?: clampedMaxZoom()
|
||||||
|
val nextZoom = ratio.toFloat().coerceIn(minZoom, maxZoom)
|
||||||
|
currentZoomRatio = nextZoom
|
||||||
|
|
||||||
|
val future = boundCamera.cameraControl.setZoomRatio(nextZoom)
|
||||||
|
future.addListener(
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
future.get()
|
||||||
|
onComplete(true, zoomCapabilitiesMap(), null)
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.e(TAG, "setZoomRatio failed", error)
|
||||||
|
onComplete(false, zoomCapabilitiesMap(), error.message)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mainExecutor,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unbind() {
|
||||||
|
activeRecording?.stop()
|
||||||
|
activeRecording = null
|
||||||
|
cameraProvider?.unbindAll()
|
||||||
|
cameraProvider = null
|
||||||
|
preview = null
|
||||||
|
videoCapture = null
|
||||||
|
camera = null
|
||||||
|
boundLifecycleOwner = null
|
||||||
|
currentLensMode = LensMode.MAIN
|
||||||
|
currentZoomRatio = 1f
|
||||||
|
ultraWideZoomRatio = DEFAULT_ULTRA_WIDE_ZOOM_RATIO
|
||||||
|
updateStatus(RecordingStatus(RecordingState.IDLE))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun elapsedMillis(): Long {
|
||||||
|
if (status.state != RecordingState.RECORDING) return 0L
|
||||||
|
return System.currentTimeMillis() - recordingStartedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateStatus(next: RecordingStatus) {
|
||||||
|
status = next
|
||||||
|
statusListener?.invoke(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyCurrentZoom() {
|
||||||
|
val boundCamera = camera ?: return
|
||||||
|
if (currentLensMode == LensMode.ULTRA_WIDE) {
|
||||||
|
currentZoomRatio = ultraWideZoomRatio
|
||||||
|
boundCamera.cameraControl.setZoomRatio(1f)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val zoomState = boundCamera.cameraInfo.zoomState.value
|
||||||
|
val minZoom = zoomState?.minZoomRatio ?: 1f
|
||||||
|
val maxZoom = zoomState?.maxZoomRatio ?: clampedMaxZoom()
|
||||||
|
currentZoomRatio = currentZoomRatio.coerceIn(minZoom, maxZoom)
|
||||||
|
boundCamera.cameraControl.setZoomRatio(currentZoomRatio)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clampedMaxZoom(): Float {
|
||||||
|
return camera?.cameraInfo?.zoomState?.value?.maxZoomRatio ?: 3f
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun discoverBackCameras(provider: ProcessCameraProvider) {
|
||||||
|
if (mainCameraId == null) {
|
||||||
|
mainCameraId = cameraIdForSelector(provider, CameraSelector.DEFAULT_BACK_CAMERA)
|
||||||
|
}
|
||||||
|
val ultraWideCamera = findUltraWideCamera(provider, mainCameraId)
|
||||||
|
ultraWideCameraId = ultraWideCamera?.cameraId
|
||||||
|
ultraWideZoomRatio = ultraWideCamera?.zoomRatio ?: DEFAULT_ULTRA_WIDE_ZOOM_RATIO
|
||||||
|
if (ultraWideCamera == null && currentLensMode == LensMode.ULTRA_WIDE) {
|
||||||
|
currentLensMode = LensMode.MAIN
|
||||||
|
currentZoomRatio = 1f
|
||||||
|
}
|
||||||
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"mainCameraId=$mainCameraId ultraWideCameraId=$ultraWideCameraId " +
|
||||||
|
"ultraWideZoomRatio=$ultraWideZoomRatio",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cameraIdForSelector(
|
||||||
|
provider: ProcessCameraProvider,
|
||||||
|
selector: CameraSelector,
|
||||||
|
): String? {
|
||||||
|
return try {
|
||||||
|
val infos = selector.filter(provider.availableCameraInfos)
|
||||||
|
infos.firstOrNull()?.let { Camera2CameraInfo.from(it).cameraId }
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "cameraIdForSelector failed", error)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findUltraWideCamera(
|
||||||
|
provider: ProcessCameraProvider,
|
||||||
|
excludedCameraId: String?,
|
||||||
|
): UltraWideCamera? {
|
||||||
|
val manager = appContext.getSystemService(Context.CAMERA_SERVICE) as CameraManager
|
||||||
|
val candidates =
|
||||||
|
manager.cameraIdList
|
||||||
|
.mapNotNull { cameraId -> backCameraProfile(manager, cameraId) }
|
||||||
|
.filter { it.cameraId != excludedCameraId }
|
||||||
|
.filter { provider.hasCameraSafely(selectorForCameraId(it.cameraId)) }
|
||||||
|
.sortedWith(
|
||||||
|
compareByDescending<CameraProfile> { it.horizontalFov }
|
||||||
|
.thenBy { it.minFocalLength },
|
||||||
|
)
|
||||||
|
|
||||||
|
val mainProfile = excludedCameraId?.let { backCameraProfile(manager, it) }
|
||||||
|
val widest = candidates.firstOrNull() ?: return null
|
||||||
|
if (mainProfile == null) {
|
||||||
|
return UltraWideCamera(widest.cameraId, DEFAULT_ULTRA_WIDE_ZOOM_RATIO)
|
||||||
|
}
|
||||||
|
|
||||||
|
val meaningfullyWider =
|
||||||
|
widest.horizontalFov > mainProfile.horizontalFov * ULTRA_WIDE_FOV_FACTOR ||
|
||||||
|
widest.minFocalLength < mainProfile.minFocalLength * ULTRA_WIDE_FOCAL_FACTOR
|
||||||
|
if (!meaningfullyWider) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return UltraWideCamera(widest.cameraId, DEFAULT_ULTRA_WIDE_ZOOM_RATIO)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun backCameraProfile(
|
||||||
|
manager: CameraManager,
|
||||||
|
cameraId: String,
|
||||||
|
): CameraProfile? {
|
||||||
|
return try {
|
||||||
|
val characteristics = manager.getCameraCharacteristics(cameraId)
|
||||||
|
val facing = characteristics.get(CameraCharacteristics.LENS_FACING)
|
||||||
|
if (facing != CameraCharacteristics.LENS_FACING_BACK) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val focalLengths =
|
||||||
|
characteristics.get(CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS)
|
||||||
|
?: return null
|
||||||
|
val physicalSize =
|
||||||
|
characteristics.get(CameraCharacteristics.SENSOR_INFO_PHYSICAL_SIZE)
|
||||||
|
?: return null
|
||||||
|
val minFocalLength = focalLengths.minOrNull() ?: return null
|
||||||
|
val horizontalFov =
|
||||||
|
2.0 * atan((physicalSize.width / (2.0f * minFocalLength)).toDouble())
|
||||||
|
CameraProfile(cameraId, minFocalLength, horizontalFov)
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "backCameraProfile failed for cameraId=$cameraId", error)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun selectorForCurrentLensMode(): CameraSelector {
|
||||||
|
val cameraId =
|
||||||
|
if (currentLensMode == LensMode.ULTRA_WIDE) {
|
||||||
|
ultraWideCameraId
|
||||||
|
} else {
|
||||||
|
mainCameraId
|
||||||
|
}
|
||||||
|
return if (cameraId != null) {
|
||||||
|
selectorForCameraId(cameraId)
|
||||||
|
} else {
|
||||||
|
CameraSelector.DEFAULT_BACK_CAMERA
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun selectorForCameraId(cameraId: String): CameraSelector {
|
||||||
|
return CameraSelector.Builder()
|
||||||
|
.addCameraFilter { cameraInfos ->
|
||||||
|
cameraInfos.filter { Camera2CameraInfo.from(it).cameraId == cameraId }
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindUseCases(
|
||||||
|
provider: ProcessCameraProvider,
|
||||||
|
lifecycleOwner: LifecycleOwner,
|
||||||
|
selector: CameraSelector,
|
||||||
|
) {
|
||||||
|
val boundPreview = preview ?: throw IllegalStateException("Preview is not ready")
|
||||||
|
val boundVideoCapture =
|
||||||
|
videoCapture ?: throw IllegalStateException("Video capture is not ready")
|
||||||
|
provider.unbindAll()
|
||||||
|
camera =
|
||||||
|
provider.bindToLifecycle(
|
||||||
|
lifecycleOwner,
|
||||||
|
selector,
|
||||||
|
boundPreview,
|
||||||
|
boundVideoCapture,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun switchToUltraWide(
|
||||||
|
onComplete: (Boolean, Map<String, Any>, String?) -> Unit,
|
||||||
|
) {
|
||||||
|
val ultraWideId = ultraWideCameraId
|
||||||
|
if (ultraWideId == null) {
|
||||||
|
onComplete(false, zoomCapabilitiesMap(), "Ultra-wide camera is unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (currentLensMode == LensMode.ULTRA_WIDE) {
|
||||||
|
currentZoomRatio = ultraWideZoomRatio
|
||||||
|
onComplete(true, zoomCapabilitiesMap(), null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (activeRecording != null) {
|
||||||
|
onComplete(false, zoomCapabilitiesMap(), "Cannot switch physical camera while recording")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val provider = cameraProvider
|
||||||
|
val lifecycleOwner = boundLifecycleOwner
|
||||||
|
if (provider == null || lifecycleOwner == null) {
|
||||||
|
onComplete(false, zoomCapabilitiesMap(), "Camera is not ready")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
currentLensMode = LensMode.ULTRA_WIDE
|
||||||
|
currentZoomRatio = ultraWideZoomRatio
|
||||||
|
bindUseCases(provider, lifecycleOwner, selectorForCameraId(ultraWideId))
|
||||||
|
applyCurrentZoom()
|
||||||
|
onComplete(true, zoomCapabilitiesMap(), null)
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.e(TAG, "switchToUltraWide failed", error)
|
||||||
|
currentLensMode = LensMode.MAIN
|
||||||
|
currentZoomRatio = 1f
|
||||||
|
try {
|
||||||
|
bindUseCases(provider, lifecycleOwner, selectorForCurrentLensMode())
|
||||||
|
applyCurrentZoom()
|
||||||
|
} catch (restoreError: Exception) {
|
||||||
|
Log.e(TAG, "restore main camera after ultra-wide failure failed", restoreError)
|
||||||
|
}
|
||||||
|
onComplete(false, zoomCapabilitiesMap(), error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun switchToMainAndZoom(
|
||||||
|
ratio: Double,
|
||||||
|
onComplete: (Boolean, Map<String, Any>, String?) -> Unit,
|
||||||
|
) {
|
||||||
|
if (activeRecording != null) {
|
||||||
|
onComplete(false, zoomCapabilitiesMap(), "Cannot switch physical camera while recording")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val provider = cameraProvider
|
||||||
|
val lifecycleOwner = boundLifecycleOwner
|
||||||
|
if (provider == null || lifecycleOwner == null) {
|
||||||
|
onComplete(false, zoomCapabilitiesMap(), "Camera is not ready")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
currentLensMode = LensMode.MAIN
|
||||||
|
currentZoomRatio = ratio.toFloat().coerceAtLeast(1f)
|
||||||
|
bindUseCases(provider, lifecycleOwner, selectorForCurrentLensMode())
|
||||||
|
setZoomRatio(ratio, onComplete)
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.e(TAG, "switchToMainAndZoom failed", error)
|
||||||
|
onComplete(false, zoomCapabilitiesMap(), error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasUltraWideCamera(): Boolean {
|
||||||
|
return ultraWideCameraId != null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ProcessCameraProvider.hasCameraSafely(selector: CameraSelector): Boolean {
|
||||||
|
return try {
|
||||||
|
hasCamera(selector)
|
||||||
|
} catch (error: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum class LensMode {
|
||||||
|
MAIN,
|
||||||
|
ULTRA_WIDE,
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class CameraProfile(
|
||||||
|
val cameraId: String,
|
||||||
|
val minFocalLength: Float,
|
||||||
|
val horizontalFov: Double,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class UltraWideCamera(
|
||||||
|
val cameraId: String,
|
||||||
|
val zoomRatio: Float,
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "RecordingCamera"
|
||||||
|
private const val DEFAULT_ULTRA_WIDE_ZOOM_RATIO = 0.6f
|
||||||
|
private const val ULTRA_WIDE_FOV_FACTOR = 1.08
|
||||||
|
private const val ULTRA_WIDE_FOCAL_FACTOR = 0.92
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.qxy.dronex.recording
|
package com.dronex.rec.recording
|
||||||
|
|
||||||
import android.app.Notification
|
import android.app.Notification
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
@@ -14,8 +14,8 @@ import android.os.PowerManager
|
|||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.lifecycle.LifecycleService
|
import androidx.lifecycle.LifecycleService
|
||||||
import com.qxy.dronex.AppConstants
|
import com.dronex.rec.AppConstants
|
||||||
import com.qxy.dronex.MainActivity
|
import com.dronex.rec.MainActivity
|
||||||
|
|
||||||
class RecordingForegroundService : LifecycleService() {
|
class RecordingForegroundService : LifecycleService() {
|
||||||
private var wakeLock: PowerManager.WakeLock? = null
|
private var wakeLock: PowerManager.WakeLock? = null
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.qxy.dronex.recording
|
package com.dronex.rec.recording
|
||||||
|
|
||||||
import android.content.ContentValues
|
import android.content.ContentValues
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
package com.qxy.dronex.recording
|
package com.dronex.rec.recording
|
||||||
|
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.core.view.WindowInsetsControllerCompat
|
import androidx.core.view.WindowInsetsControllerCompat
|
||||||
import com.qxy.dronex.AppConstants
|
import com.dronex.rec.AppConstants
|
||||||
import com.qxy.dronex.MainActivity
|
import com.dronex.rec.MainActivity
|
||||||
import io.flutter.plugin.common.BinaryMessenger
|
import io.flutter.plugin.common.BinaryMessenger
|
||||||
import io.flutter.plugin.common.EventChannel
|
import io.flutter.plugin.common.EventChannel
|
||||||
import io.flutter.plugin.common.MethodCall
|
import io.flutter.plugin.common.MethodCall
|
||||||
@@ -50,6 +50,11 @@ class RecordingPlatformHandler(
|
|||||||
startRecording(withAudio, enableDnd, displayName, result)
|
startRecording(withAudio, enableDnd, displayName, result)
|
||||||
}
|
}
|
||||||
"stopRecording" -> stopRecording(result)
|
"stopRecording" -> stopRecording(result)
|
||||||
|
"getZoomCapabilities" -> result.success(controller.zoomCapabilitiesMap())
|
||||||
|
"setZoomRatio" -> {
|
||||||
|
val ratio = call.argument<Double>("zoomRatio") ?: 1.0
|
||||||
|
setZoomRatio(ratio, result)
|
||||||
|
}
|
||||||
"disposePreview" -> {
|
"disposePreview" -> {
|
||||||
controller.unbind()
|
controller.unbind()
|
||||||
result.success(null)
|
result.success(null)
|
||||||
@@ -172,6 +177,18 @@ class RecordingPlatformHandler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun setZoomRatio(ratio: Double, result: MethodChannel.Result) {
|
||||||
|
controller.setZoomRatio(ratio) { success, capabilities, message ->
|
||||||
|
mainHandler.post {
|
||||||
|
if (success) {
|
||||||
|
result.success(capabilities)
|
||||||
|
} else {
|
||||||
|
result.error("ZOOM_FAILED", message ?: "Failed to set camera zoom", null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun deliverStopResult(result: MethodChannel.Result, path: String?) {
|
private fun deliverStopResult(result: MethodChannel.Result, path: String?) {
|
||||||
val gallerySaved = path != null && controller.status.state != RecordingState.ERROR
|
val gallerySaved = path != null && controller.status.state != RecordingState.ERROR
|
||||||
val payload =
|
val payload =
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
package com.qxy.dronex.recording
|
package com.dronex.rec.recording
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.camera.view.PreviewView
|
import androidx.camera.view.PreviewView
|
||||||
import com.qxy.dronex.MainActivity
|
import com.dronex.rec.MainActivity
|
||||||
import io.flutter.plugin.common.StandardMessageCodec
|
import io.flutter.plugin.common.StandardMessageCodec
|
||||||
import io.flutter.plugin.platform.PlatformView
|
import io.flutter.plugin.platform.PlatformView
|
||||||
import io.flutter.plugin.platform.PlatformViewFactory
|
import io.flutter.plugin.platform.PlatformViewFactory
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.qxy.dronex.recording
|
package com.dronex.rec.recording
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.lifecycle.LifecycleService
|
import androidx.lifecycle.LifecycleService
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.qxy.dronex.recording
|
package com.dronex.rec.recording
|
||||||
|
|
||||||
enum class RecordingState {
|
enum class RecordingState {
|
||||||
IDLE,
|
IDLE,
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
package com.qxy.dronex.recording
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.camera.core.CameraSelector
|
|
||||||
import androidx.camera.core.Preview
|
|
||||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
|
||||||
import androidx.camera.video.Quality
|
|
||||||
import androidx.camera.video.QualitySelector
|
|
||||||
import androidx.camera.video.Recorder
|
|
||||||
import androidx.camera.video.Recording
|
|
||||||
import androidx.camera.video.VideoCapture
|
|
||||||
import androidx.camera.video.VideoRecordEvent
|
|
||||||
import androidx.camera.view.PreviewView
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.lifecycle.LifecycleOwner
|
|
||||||
import java.util.concurrent.Executor
|
|
||||||
|
|
||||||
class RecordingCameraController(
|
|
||||||
private val appContext: Context,
|
|
||||||
) {
|
|
||||||
private val mainExecutor: Executor = ContextCompat.getMainExecutor(appContext)
|
|
||||||
|
|
||||||
private var cameraProvider: ProcessCameraProvider? = null
|
|
||||||
private var preview: Preview? = null
|
|
||||||
private var videoCapture: VideoCapture<Recorder>? = null
|
|
||||||
private var activeRecording: Recording? = null
|
|
||||||
private var boundLifecycleOwner: LifecycleOwner? = null
|
|
||||||
|
|
||||||
var status: RecordingStatus = RecordingStatus(RecordingState.IDLE)
|
|
||||||
private set
|
|
||||||
|
|
||||||
var statusListener: ((RecordingStatus) -> Unit)? = null
|
|
||||||
|
|
||||||
private var recordingStartedAt: Long = 0L
|
|
||||||
private var latestOutputPath: String? = null
|
|
||||||
private var pendingStopCallback: ((String?) -> Unit)? = null
|
|
||||||
|
|
||||||
fun bindPreview(
|
|
||||||
lifecycleOwner: LifecycleOwner,
|
|
||||||
previewView: PreviewView,
|
|
||||||
onReady: (Boolean) -> Unit,
|
|
||||||
) {
|
|
||||||
val future = ProcessCameraProvider.getInstance(appContext)
|
|
||||||
future.addListener(
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
val provider = future.get()
|
|
||||||
cameraProvider = provider
|
|
||||||
boundLifecycleOwner = lifecycleOwner
|
|
||||||
|
|
||||||
preview =
|
|
||||||
Preview.Builder().build().also {
|
|
||||||
it.surfaceProvider = previewView.surfaceProvider
|
|
||||||
}
|
|
||||||
|
|
||||||
val recorder =
|
|
||||||
Recorder.Builder()
|
|
||||||
.setQualitySelector(QualitySelector.from(Quality.HD))
|
|
||||||
.build()
|
|
||||||
videoCapture = VideoCapture.withOutput(recorder)
|
|
||||||
|
|
||||||
provider.unbindAll()
|
|
||||||
provider.bindToLifecycle(
|
|
||||||
lifecycleOwner,
|
|
||||||
CameraSelector.DEFAULT_BACK_CAMERA,
|
|
||||||
preview,
|
|
||||||
videoCapture,
|
|
||||||
)
|
|
||||||
|
|
||||||
updateStatus(RecordingStatus(RecordingState.PREVIEWING))
|
|
||||||
onReady(true)
|
|
||||||
} catch (error: Exception) {
|
|
||||||
Log.e(TAG, "bindPreview failed", error)
|
|
||||||
updateStatus(
|
|
||||||
RecordingStatus(
|
|
||||||
RecordingState.ERROR,
|
|
||||||
message = error.message,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
onReady(false)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mainExecutor,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun rebindForRecording(
|
|
||||||
lifecycleOwner: LifecycleOwner,
|
|
||||||
previewView: PreviewView,
|
|
||||||
onReady: (Boolean) -> Unit,
|
|
||||||
) {
|
|
||||||
val provider = cameraProvider
|
|
||||||
if (provider == null) {
|
|
||||||
bindPreview(lifecycleOwner, previewView, onReady)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
boundLifecycleOwner === lifecycleOwner &&
|
|
||||||
preview != null &&
|
|
||||||
videoCapture != null
|
|
||||||
) {
|
|
||||||
onReady(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
boundLifecycleOwner = lifecycleOwner
|
|
||||||
provider.unbindAll()
|
|
||||||
provider.bindToLifecycle(
|
|
||||||
lifecycleOwner,
|
|
||||||
CameraSelector.DEFAULT_BACK_CAMERA,
|
|
||||||
preview,
|
|
||||||
videoCapture,
|
|
||||||
)
|
|
||||||
onReady(true)
|
|
||||||
} catch (error: Exception) {
|
|
||||||
Log.e(TAG, "rebindForRecording failed", error)
|
|
||||||
onReady(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun startRecording(
|
|
||||||
withAudio: Boolean,
|
|
||||||
displayName: String?,
|
|
||||||
onStarted: (Boolean, String?) -> Unit,
|
|
||||||
) {
|
|
||||||
val capture = videoCapture
|
|
||||||
if (capture == null || boundLifecycleOwner == null) {
|
|
||||||
onStarted(false, "Camera not ready")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeRecording != null) {
|
|
||||||
onStarted(false, "Already recording")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val outputOptions =
|
|
||||||
RecordingOutputFactory.buildMediaStoreOutputOptions(
|
|
||||||
appContext,
|
|
||||||
displayName,
|
|
||||||
)
|
|
||||||
latestOutputPath = null
|
|
||||||
|
|
||||||
val pending =
|
|
||||||
capture.output.prepareRecording(appContext, outputOptions).apply {
|
|
||||||
if (withAudio) {
|
|
||||||
val granted =
|
|
||||||
ContextCompat.checkSelfPermission(
|
|
||||||
appContext,
|
|
||||||
android.Manifest.permission.RECORD_AUDIO,
|
|
||||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
|
||||||
if (granted) {
|
|
||||||
withAudioEnabled()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
recordingStartedAt = System.currentTimeMillis()
|
|
||||||
updateStatus(
|
|
||||||
RecordingStatus(
|
|
||||||
RecordingState.RECORDING,
|
|
||||||
outputPath = latestOutputPath,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
activeRecording =
|
|
||||||
pending.start(mainExecutor) { event ->
|
|
||||||
when (event) {
|
|
||||||
is VideoRecordEvent.Start -> Unit
|
|
||||||
is VideoRecordEvent.Finalize -> {
|
|
||||||
activeRecording = null
|
|
||||||
if (event.hasError()) {
|
|
||||||
updateStatus(
|
|
||||||
RecordingStatus(
|
|
||||||
RecordingState.ERROR,
|
|
||||||
message = event.cause?.message
|
|
||||||
?: "Recording failed",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
latestOutputPath = event.outputResults.outputUri.toString()
|
|
||||||
updateStatus(
|
|
||||||
RecordingStatus(
|
|
||||||
RecordingState.PREVIEWING,
|
|
||||||
outputPath = latestOutputPath,
|
|
||||||
elapsedMillis =
|
|
||||||
System.currentTimeMillis() -
|
|
||||||
recordingStartedAt,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val stopCallback = pendingStopCallback
|
|
||||||
pendingStopCallback = null
|
|
||||||
stopCallback?.invoke(latestOutputPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onStarted(true, latestOutputPath ?: "recording")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stopRecording(onStopped: (String?) -> Unit) {
|
|
||||||
val recording = activeRecording
|
|
||||||
if (recording == null) {
|
|
||||||
onStopped(latestOutputPath)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
pendingStopCallback = onStopped
|
|
||||||
updateStatus(
|
|
||||||
RecordingStatus(
|
|
||||||
RecordingState.STOPPING,
|
|
||||||
outputPath = latestOutputPath,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
recording.stop()
|
|
||||||
activeRecording = null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun unbind() {
|
|
||||||
activeRecording?.stop()
|
|
||||||
activeRecording = null
|
|
||||||
cameraProvider?.unbindAll()
|
|
||||||
cameraProvider = null
|
|
||||||
preview = null
|
|
||||||
videoCapture = null
|
|
||||||
boundLifecycleOwner = null
|
|
||||||
updateStatus(RecordingStatus(RecordingState.IDLE))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun elapsedMillis(): Long {
|
|
||||||
if (status.state != RecordingState.RECORDING) return 0L
|
|
||||||
return System.currentTimeMillis() - recordingStartedAt
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateStatus(next: RecordingStatus) {
|
|
||||||
status = next
|
|
||||||
statusListener?.invoke(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val TAG = "RecordingCamera"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -2,9 +2,6 @@ PODS:
|
|||||||
- connectivity_plus (0.0.1):
|
- connectivity_plus (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- Flutter (1.0.0)
|
- Flutter (1.0.0)
|
||||||
- path_provider_foundation (0.0.1):
|
|
||||||
- Flutter
|
|
||||||
- FlutterMacOS
|
|
||||||
- permission_handler_apple (9.4.8):
|
- permission_handler_apple (9.4.8):
|
||||||
- Flutter
|
- Flutter
|
||||||
- shared_preferences_foundation (0.0.1):
|
- shared_preferences_foundation (0.0.1):
|
||||||
@@ -19,7 +16,6 @@ PODS:
|
|||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
||||||
- Flutter (from `Flutter`)
|
- Flutter (from `Flutter`)
|
||||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
|
||||||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||||
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
||||||
@@ -30,8 +26,6 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/connectivity_plus/ios"
|
:path: ".symlinks/plugins/connectivity_plus/ios"
|
||||||
Flutter:
|
Flutter:
|
||||||
:path: Flutter
|
:path: Flutter
|
||||||
path_provider_foundation:
|
|
||||||
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
|
||||||
permission_handler_apple:
|
permission_handler_apple:
|
||||||
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
||||||
shared_preferences_foundation:
|
shared_preferences_foundation:
|
||||||
@@ -44,7 +38,6 @@ EXTERNAL SOURCES:
|
|||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
||||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||||
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
|
||||||
permission_handler_apple: 92d754bbaa7361d436db2d6c3c1c2a0fdcec462e
|
permission_handler_apple: 92d754bbaa7361d436db2d6c3c1c2a0fdcec462e
|
||||||
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||||
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
||||||
|
|||||||
@@ -344,10 +344,14 @@
|
|||||||
inputFileListPaths = (
|
inputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||||
);
|
);
|
||||||
|
inputPaths = (
|
||||||
|
);
|
||||||
name = "[CP] Embed Pods Frameworks";
|
name = "[CP] Embed Pods Frameworks";
|
||||||
outputFileListPaths = (
|
outputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||||
);
|
);
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||||
@@ -376,10 +380,14 @@
|
|||||||
inputFileListPaths = (
|
inputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||||
);
|
);
|
||||||
|
inputPaths = (
|
||||||
|
);
|
||||||
name = "[CP] Copy Pods Resources";
|
name = "[CP] Copy Pods Resources";
|
||||||
outputFileListPaths = (
|
outputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||||
);
|
);
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
||||||
@@ -495,16 +503,22 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
|
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||||
|
CODE_SIGN_STYLE = Manual;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
DEVELOPMENT_TEAM = 35634V629S;
|
DEVELOPMENT_TEAM = "";
|
||||||
|
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = MT26BPCKF6;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.qxy.dronex;
|
PRODUCT_BUNDLE_IDENTIFIER = com.dronex.rec;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
|
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "dev-profile-dronex";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
VERSIONING_SYSTEM = "apple-generic";
|
VERSIONING_SYSTEM = "apple-generic";
|
||||||
@@ -678,16 +692,22 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
|
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||||
|
CODE_SIGN_STYLE = Manual;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
DEVELOPMENT_TEAM = 35634V629S;
|
DEVELOPMENT_TEAM = "";
|
||||||
|
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = MT26BPCKF6;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.qxy.dronex;
|
PRODUCT_BUNDLE_IDENTIFIER = com.dronex.rec;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
|
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "dev-profile-dronex";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
@@ -701,16 +721,22 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
|
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||||
|
CODE_SIGN_STYLE = Manual;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
DEVELOPMENT_TEAM = 35634V629S;
|
DEVELOPMENT_TEAM = "";
|
||||||
|
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = MT26BPCKF6;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.qxy.dronex;
|
PRODUCT_BUNDLE_IDENTIFIER = com.dronex.rec;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
|
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "dev-profile-dronex";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
VERSIONING_SYSTEM = "apple-generic";
|
VERSIONING_SYSTEM = "apple-generic";
|
||||||
|
|||||||
13
ios/Runner/Assets.xcassets/StartupBackground.imageset/Contents.json
vendored
Normal file
13
ios/Runner/Assets.xcassets/StartupBackground.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "startup_background.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
ios/Runner/Assets.xcassets/StartupBackground.imageset/startup_background.png
vendored
Normal file
BIN
ios/Runner/Assets.xcassets/StartupBackground.imageset/startup_background.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 262 KiB |
@@ -16,13 +16,15 @@
|
|||||||
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
|
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" image="StartupBackground" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
|
||||||
</imageView>
|
</imageView>
|
||||||
</subviews>
|
</subviews>
|
||||||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||||
<constraints>
|
<constraints>
|
||||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
|
<constraint firstItem="YRO-k0-Ey4" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="1a2-6s-vTC"/>
|
||||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
|
<constraint firstItem="YRO-k0-Ey4" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="4X2-HB-R7a"/>
|
||||||
|
<constraint firstItem="YRO-k0-Ey4" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="E8f-e3-JEx"/>
|
||||||
|
<constraint firstItem="YRO-k0-Ey4" firstAttribute="trailing" secondItem="Ze5-6b-2t3" secondAttribute="trailing" id="rwG-Bh-0uU"/>
|
||||||
</constraints>
|
</constraints>
|
||||||
</view>
|
</view>
|
||||||
</viewController>
|
</viewController>
|
||||||
@@ -32,6 +34,6 @@
|
|||||||
</scene>
|
</scene>
|
||||||
</scenes>
|
</scenes>
|
||||||
<resources>
|
<resources>
|
||||||
<image name="LaunchImage" width="168" height="185"/>
|
<image name="StartupBackground" width="750" height="1624"/>
|
||||||
</resources>
|
</resources>
|
||||||
</document>
|
</document>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
|
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="24412" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
|
||||||
|
<device id="retina6_12" orientation="portrait" appearance="light"/>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<deployment identifier="iOS"/>
|
<deployment identifier="iOS"/>
|
||||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
|
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="24405"/>
|
||||||
|
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
<scenes>
|
<scenes>
|
||||||
<!--Flutter View Controller-->
|
<!--Flutter View Controller-->
|
||||||
@@ -14,13 +16,28 @@
|
|||||||
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
|
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
|
||||||
</layoutGuides>
|
</layoutGuides>
|
||||||
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
|
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
|
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
|
||||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
|
<subviews>
|
||||||
|
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" image="StartupBackground" translatesAutoresizingMaskIntoConstraints="NO" id="YQm-Ov-qw7">
|
||||||
|
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
|
||||||
|
</imageView>
|
||||||
|
</subviews>
|
||||||
|
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||||
|
<constraints>
|
||||||
|
<constraint firstItem="YQm-Ov-qw7" firstAttribute="top" secondItem="8bC-Xf-vdC" secondAttribute="top" id="7pp-OK-Dgk"/>
|
||||||
|
<constraint firstItem="YQm-Ov-qw7" firstAttribute="bottom" secondItem="8bC-Xf-vdC" secondAttribute="bottom" id="Hbf-gY-rbf"/>
|
||||||
|
<constraint firstItem="YQm-Ov-qw7" firstAttribute="leading" secondItem="8bC-Xf-vdC" secondAttribute="leading" id="WPc-7k-20p"/>
|
||||||
|
<constraint firstItem="YQm-Ov-qw7" firstAttribute="trailing" secondItem="8bC-Xf-vdC" secondAttribute="trailing" id="w1G-WA-3QR"/>
|
||||||
|
</constraints>
|
||||||
</view>
|
</view>
|
||||||
</viewController>
|
</viewController>
|
||||||
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
||||||
</objects>
|
</objects>
|
||||||
|
<point key="canvasLocation" x="139" y="122"/>
|
||||||
</scene>
|
</scene>
|
||||||
</scenes>
|
</scenes>
|
||||||
|
<resources>
|
||||||
|
<image name="StartupBackground" width="750" height="1624"/>
|
||||||
|
</resources>
|
||||||
</document>
|
</document>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import UIKit
|
|||||||
final class PlatformInfoPlugin: NSObject, FlutterPlugin {
|
final class PlatformInfoPlugin: NSObject, FlutterPlugin {
|
||||||
static func register(with registrar: FlutterPluginRegistrar) {
|
static func register(with registrar: FlutterPluginRegistrar) {
|
||||||
let channel = FlutterMethodChannel(
|
let channel = FlutterMethodChannel(
|
||||||
name: "com.qxy.dronex/platform_info",
|
name: "com.dronex.rec/platform_info",
|
||||||
binaryMessenger: registrar.messenger()
|
binaryMessenger: registrar.messenger()
|
||||||
)
|
)
|
||||||
let plugin = PlatformInfoPlugin()
|
let plugin = PlatformInfoPlugin()
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
|||||||
private var recordingStartedAt: Date?
|
private var recordingStartedAt: Date?
|
||||||
private var elapsedTimer: Timer?
|
private var elapsedTimer: Timer?
|
||||||
private var pendingStopResult: FlutterResult?
|
private var pendingStopResult: FlutterResult?
|
||||||
|
private var currentZoomRatio: CGFloat = 1.0
|
||||||
|
|
||||||
private(set) var status = RecordingStatus(state: .idle) {
|
private(set) var status = RecordingStatus(state: .idle) {
|
||||||
didSet {
|
didSet {
|
||||||
@@ -291,6 +292,7 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
|||||||
self.session.commitConfiguration()
|
self.session.commitConfiguration()
|
||||||
self.videoInput = nil
|
self.videoInput = nil
|
||||||
self.audioInput = nil
|
self.audioInput = nil
|
||||||
|
self.currentZoomRatio = 1.0
|
||||||
self.configured = false
|
self.configured = false
|
||||||
self.updateStatus(RecordingStatus(state: .idle))
|
self.updateStatus(RecordingStatus(state: .idle))
|
||||||
|
|
||||||
@@ -312,6 +314,52 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
|||||||
return status.toMap()
|
return status.toMap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func zoomCapabilities(result: @escaping FlutterResult) {
|
||||||
|
sessionQueue.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
let capabilities = self.currentZoomCapabilitiesMap()
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
result(capabilities)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setZoomRatio(_ ratio: CGFloat, result: @escaping FlutterResult) {
|
||||||
|
sessionQueue.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
guard let device = self.videoInput?.device else {
|
||||||
|
self.currentZoomRatio = max(1.0, ratio)
|
||||||
|
let capabilities = self.currentZoomCapabilitiesMap()
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
result(capabilities)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let nextZoom = self.clampedZoomRatio(ratio, for: device)
|
||||||
|
try device.lockForConfiguration()
|
||||||
|
device.videoZoomFactor = nextZoom
|
||||||
|
device.unlockForConfiguration()
|
||||||
|
self.currentZoomRatio = nextZoom
|
||||||
|
let capabilities = self.currentZoomCapabilitiesMap()
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
result(capabilities)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
result(
|
||||||
|
FlutterError(
|
||||||
|
code: "ZOOM_FAILED",
|
||||||
|
message: error.localizedDescription,
|
||||||
|
details: nil
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func fileOutput(
|
func fileOutput(
|
||||||
_ output: AVCaptureFileOutput,
|
_ output: AVCaptureFileOutput,
|
||||||
didFinishRecordingTo outputFileURL: URL,
|
didFinishRecordingTo outputFileURL: URL,
|
||||||
@@ -465,9 +513,43 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
|||||||
session.commitConfiguration()
|
session.commitConfiguration()
|
||||||
|
|
||||||
configured = true
|
configured = true
|
||||||
|
try applyCurrentZoom()
|
||||||
try configureAudioInput(enabled: withAudio)
|
try configureAudioInput(enabled: withAudio)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func currentZoomCapabilitiesMap() -> [String: Any] {
|
||||||
|
guard let device = videoInput?.device else {
|
||||||
|
return [
|
||||||
|
"zoomRatio": Double(currentZoomRatio),
|
||||||
|
"minZoomRatio": 1.0,
|
||||||
|
"maxZoomRatio": 3.0,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
let minZoom = device.minAvailableVideoZoomFactor
|
||||||
|
let maxZoom = device.maxAvailableVideoZoomFactor
|
||||||
|
let zoom = clampedZoomRatio(device.videoZoomFactor, for: device)
|
||||||
|
currentZoomRatio = zoom
|
||||||
|
return [
|
||||||
|
"zoomRatio": Double(zoom),
|
||||||
|
"minZoomRatio": Double(minZoom),
|
||||||
|
"maxZoomRatio": Double(maxZoom),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyCurrentZoom() throws {
|
||||||
|
guard let device = videoInput?.device else { return }
|
||||||
|
let nextZoom = clampedZoomRatio(currentZoomRatio, for: device)
|
||||||
|
try device.lockForConfiguration()
|
||||||
|
device.videoZoomFactor = nextZoom
|
||||||
|
device.unlockForConfiguration()
|
||||||
|
currentZoomRatio = nextZoom
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clampedZoomRatio(_ ratio: CGFloat, for device: AVCaptureDevice) -> CGFloat {
|
||||||
|
min(max(ratio, device.minAvailableVideoZoomFactor), device.maxAvailableVideoZoomFactor)
|
||||||
|
}
|
||||||
|
|
||||||
private func configureAudioInput(enabled: Bool) throws {
|
private func configureAudioInput(enabled: Bool) throws {
|
||||||
session.beginConfiguration()
|
session.beginConfiguration()
|
||||||
defer { session.commitConfiguration() }
|
defer { session.commitConfiguration() }
|
||||||
@@ -544,7 +626,7 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
|||||||
}
|
}
|
||||||
|
|
||||||
private enum RecordingChannelNames {
|
private enum RecordingChannelNames {
|
||||||
static let packageName = "com.qxy.dronex"
|
static let packageName = "com.dronex.rec"
|
||||||
static let method = "\(packageName)/recording"
|
static let method = "\(packageName)/recording"
|
||||||
static let events = "\(packageName)/recording_events"
|
static let events = "\(packageName)/recording_events"
|
||||||
}
|
}
|
||||||
@@ -587,6 +669,12 @@ final class RecordingPlugin: NSObject, FlutterPlugin, FlutterStreamHandler {
|
|||||||
controller.startRecording(withAudio: withAudio, displayName: displayName, result: result)
|
controller.startRecording(withAudio: withAudio, displayName: displayName, result: result)
|
||||||
case "stopRecording":
|
case "stopRecording":
|
||||||
controller.stopRecording(result: result)
|
controller.stopRecording(result: result)
|
||||||
|
case "getZoomCapabilities":
|
||||||
|
controller.zoomCapabilities(result: result)
|
||||||
|
case "setZoomRatio":
|
||||||
|
let args = call.arguments as? [String: Any]
|
||||||
|
let ratio = args?["zoomRatio"] as? Double ?? 1.0
|
||||||
|
controller.setZoomRatio(CGFloat(ratio), result: result)
|
||||||
case "disposePreview":
|
case "disposePreview":
|
||||||
controller.disposePreview(result: result)
|
controller.disposePreview(result: result)
|
||||||
case "getStatus":
|
case "getStatus":
|
||||||
|
|||||||
@@ -37,12 +37,12 @@ class AppConfig {
|
|||||||
),
|
),
|
||||||
AppEnvironment.staging => const EnvironmentValues(
|
AppEnvironment.staging => const EnvironmentValues(
|
||||||
environment: AppEnvironment.staging,
|
environment: AppEnvironment.staging,
|
||||||
baseUrl: 'https://staging.example.com/api',
|
baseUrl: 'https://example.com/api',
|
||||||
enableNetworkLog: true,
|
enableNetworkLog: true,
|
||||||
),
|
),
|
||||||
AppEnvironment.prod => const EnvironmentValues(
|
AppEnvironment.prod => const EnvironmentValues(
|
||||||
environment: AppEnvironment.prod,
|
environment: AppEnvironment.prod,
|
||||||
baseUrl: 'https://api.example.com',
|
baseUrl: 'https://example.com/api',
|
||||||
enableNetworkLog: false,
|
enableNetworkLog: false,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ class AppPlatformInfo {
|
|||||||
AppPlatformInfo._();
|
AppPlatformInfo._();
|
||||||
|
|
||||||
static const MethodChannel _channel = MethodChannel(
|
static const MethodChannel _channel = MethodChannel(
|
||||||
'com.qxy.dronex/platform_info',
|
'com.dronex.rec/platform_info',
|
||||||
);
|
);
|
||||||
|
|
||||||
static Future<AppPackageInfo> packageInfo() async {
|
static Future<AppPackageInfo> packageInfo() async {
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ class RecordingSessionState {
|
|||||||
this.isBatteryOptimizedIgnored = true,
|
this.isBatteryOptimizedIgnored = true,
|
||||||
this.notificationsGranted = true,
|
this.notificationsGranted = true,
|
||||||
this.isMicrophoneGranted = false,
|
this.isMicrophoneGranted = false,
|
||||||
|
this.zoomRatio = 1.0,
|
||||||
|
this.minZoomRatio = 1.0,
|
||||||
|
this.maxZoomRatio = 3.0,
|
||||||
this.lastOutputPath,
|
this.lastOutputPath,
|
||||||
this.lastSavedDisplayName,
|
this.lastSavedDisplayName,
|
||||||
this.errorMessage,
|
this.errorMessage,
|
||||||
@@ -26,6 +29,9 @@ class RecordingSessionState {
|
|||||||
final bool isBatteryOptimizedIgnored;
|
final bool isBatteryOptimizedIgnored;
|
||||||
final bool notificationsGranted;
|
final bool notificationsGranted;
|
||||||
final bool isMicrophoneGranted;
|
final bool isMicrophoneGranted;
|
||||||
|
final double zoomRatio;
|
||||||
|
final double minZoomRatio;
|
||||||
|
final double maxZoomRatio;
|
||||||
final String? lastOutputPath;
|
final String? lastOutputPath;
|
||||||
final String? lastSavedDisplayName;
|
final String? lastSavedDisplayName;
|
||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
@@ -51,6 +57,9 @@ class RecordingSessionState {
|
|||||||
bool? isBatteryOptimizedIgnored,
|
bool? isBatteryOptimizedIgnored,
|
||||||
bool? notificationsGranted,
|
bool? notificationsGranted,
|
||||||
bool? isMicrophoneGranted,
|
bool? isMicrophoneGranted,
|
||||||
|
double? zoomRatio,
|
||||||
|
double? minZoomRatio,
|
||||||
|
double? maxZoomRatio,
|
||||||
String? lastOutputPath,
|
String? lastOutputPath,
|
||||||
String? lastSavedDisplayName,
|
String? lastSavedDisplayName,
|
||||||
String? errorMessage,
|
String? errorMessage,
|
||||||
@@ -69,6 +78,9 @@ class RecordingSessionState {
|
|||||||
isBatteryOptimizedIgnored ?? this.isBatteryOptimizedIgnored,
|
isBatteryOptimizedIgnored ?? this.isBatteryOptimizedIgnored,
|
||||||
notificationsGranted: notificationsGranted ?? this.notificationsGranted,
|
notificationsGranted: notificationsGranted ?? this.notificationsGranted,
|
||||||
isMicrophoneGranted: isMicrophoneGranted ?? this.isMicrophoneGranted,
|
isMicrophoneGranted: isMicrophoneGranted ?? this.isMicrophoneGranted,
|
||||||
|
zoomRatio: zoomRatio ?? this.zoomRatio,
|
||||||
|
minZoomRatio: minZoomRatio ?? this.minZoomRatio,
|
||||||
|
maxZoomRatio: maxZoomRatio ?? this.maxZoomRatio,
|
||||||
lastOutputPath: lastOutputPath ?? this.lastOutputPath,
|
lastOutputPath: lastOutputPath ?? this.lastOutputPath,
|
||||||
lastSavedDisplayName: clearLastSaved
|
lastSavedDisplayName: clearLastSaved
|
||||||
? null
|
? null
|
||||||
|
|||||||
@@ -357,10 +357,7 @@ class _PreviewLoadingLayer extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _RecordingHudLayer extends ConsumerWidget {
|
class _RecordingHudLayer extends ConsumerWidget {
|
||||||
const _RecordingHudLayer({
|
const _RecordingHudLayer({required this.onStart, required this.onStop});
|
||||||
required this.onStart,
|
|
||||||
required this.onStop,
|
|
||||||
});
|
|
||||||
|
|
||||||
final Future<void> Function() onStart;
|
final Future<void> Function() onStart;
|
||||||
final Future<void> Function() onStop;
|
final Future<void> Function() onStop;
|
||||||
@@ -378,6 +375,9 @@ class _RecordingHudLayer extends ConsumerWidget {
|
|||||||
m.session.isRecording,
|
m.session.isRecording,
|
||||||
m.session.isStartingRecording,
|
m.session.isStartingRecording,
|
||||||
m.session.isTouchLocked,
|
m.session.isTouchLocked,
|
||||||
|
m.session.zoomRatio,
|
||||||
|
m.session.minZoomRatio,
|
||||||
|
m.session.maxZoomRatio,
|
||||||
m.hasValidClipboardInfo,
|
m.hasValidClipboardInfo,
|
||||||
m.clipboardRecordingModel.address.trim(),
|
m.clipboardRecordingModel.address.trim(),
|
||||||
),
|
),
|
||||||
@@ -392,6 +392,9 @@ class _RecordingHudLayer extends ConsumerWidget {
|
|||||||
isRecording,
|
isRecording,
|
||||||
isStartingRecording,
|
isStartingRecording,
|
||||||
isTouchLocked,
|
isTouchLocked,
|
||||||
|
zoomRatio,
|
||||||
|
minZoomRatio,
|
||||||
|
maxZoomRatio,
|
||||||
showClipboardHint,
|
showClipboardHint,
|
||||||
clipboardAddress,
|
clipboardAddress,
|
||||||
) = hudState;
|
) = hudState;
|
||||||
@@ -406,6 +409,9 @@ class _RecordingHudLayer extends ConsumerWidget {
|
|||||||
isRecording: isRecording,
|
isRecording: isRecording,
|
||||||
isStartingRecording: isStartingRecording,
|
isStartingRecording: isStartingRecording,
|
||||||
isTouchLocked: isTouchLocked,
|
isTouchLocked: isTouchLocked,
|
||||||
|
zoomRatio: zoomRatio,
|
||||||
|
minZoomRatio: minZoomRatio,
|
||||||
|
maxZoomRatio: maxZoomRatio,
|
||||||
showClipboardHint: showClipboardHint,
|
showClipboardHint: showClipboardHint,
|
||||||
clipboardAddress: clipboardAddress,
|
clipboardAddress: clipboardAddress,
|
||||||
onStart: onStart,
|
onStart: onStart,
|
||||||
@@ -419,9 +425,15 @@ class _RecordingHudLayer extends ConsumerWidget {
|
|||||||
await viewModel.refreshBatteryOptimization();
|
await viewModel.refreshBatteryOptimization();
|
||||||
},
|
},
|
||||||
onToggleTouchLock: () {
|
onToggleTouchLock: () {
|
||||||
final locked = ref.read(recordingViewModelProvider).session.isTouchLocked;
|
final locked = ref
|
||||||
|
.read(recordingViewModelProvider)
|
||||||
|
.session
|
||||||
|
.isTouchLocked;
|
||||||
viewModel.setTouchLocked(!locked);
|
viewModel.setTouchLocked(!locked);
|
||||||
},
|
},
|
||||||
|
onZoomSelected: (ratio) async {
|
||||||
|
await viewModel.setZoomRatio(ratio);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
abstract final class RecordingChannelNames {
|
abstract final class RecordingChannelNames {
|
||||||
static const packageName = 'com.qxy.dronex';
|
static const packageName = 'com.dronex.rec';
|
||||||
static const method = '$packageName/recording';
|
static const method = '$packageName/recording';
|
||||||
static const events = '$packageName/recording_events';
|
static const events = '$packageName/recording_events';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,21 @@ class RecordingPlatform {
|
|||||||
return RecordingStatus.fromMap(result ?? const {});
|
return RecordingStatus.fromMap(result ?? const {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<RecordingZoomCapabilities> getZoomCapabilities() async {
|
||||||
|
final result = await _channel.invokeMapMethod<String, dynamic>(
|
||||||
|
'getZoomCapabilities',
|
||||||
|
);
|
||||||
|
return RecordingZoomCapabilities.fromMap(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<RecordingZoomCapabilities> setZoomRatio(double ratio) async {
|
||||||
|
final result = await _channel.invokeMapMethod<String, dynamic>(
|
||||||
|
'setZoomRatio',
|
||||||
|
<String, dynamic>{'zoomRatio': ratio},
|
||||||
|
);
|
||||||
|
return RecordingZoomCapabilities.fromMap(result);
|
||||||
|
}
|
||||||
|
|
||||||
static Future<RecordingStartResult> startRecording({
|
static Future<RecordingStartResult> startRecording({
|
||||||
bool withAudio = true,
|
bool withAudio = true,
|
||||||
bool enableDoNotDisturb = true,
|
bool enableDoNotDisturb = true,
|
||||||
@@ -156,6 +171,29 @@ class RecordingPlatform {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class RecordingZoomCapabilities {
|
||||||
|
const RecordingZoomCapabilities({
|
||||||
|
required this.zoomRatio,
|
||||||
|
required this.minZoomRatio,
|
||||||
|
required this.maxZoomRatio,
|
||||||
|
});
|
||||||
|
|
||||||
|
final double zoomRatio;
|
||||||
|
final double minZoomRatio;
|
||||||
|
final double maxZoomRatio;
|
||||||
|
|
||||||
|
factory RecordingZoomCapabilities.fromMap(Map<String, dynamic>? map) {
|
||||||
|
final minZoomRatio = (map?['minZoomRatio'] as num?)?.toDouble() ?? 1.0;
|
||||||
|
final maxZoomRatio = (map?['maxZoomRatio'] as num?)?.toDouble() ?? 3.0;
|
||||||
|
final zoomRatio = (map?['zoomRatio'] as num?)?.toDouble() ?? minZoomRatio;
|
||||||
|
return RecordingZoomCapabilities(
|
||||||
|
zoomRatio: zoomRatio.clamp(minZoomRatio, maxZoomRatio).toDouble(),
|
||||||
|
minZoomRatio: minZoomRatio,
|
||||||
|
maxZoomRatio: maxZoomRatio,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class RecordingStartResult {
|
class RecordingStartResult {
|
||||||
const RecordingStartResult({this.outputPath, required this.status});
|
const RecordingStartResult({this.outputPath, required this.status});
|
||||||
|
|
||||||
|
|||||||
@@ -193,6 +193,7 @@ class RecordingViewModel extends Notifier<RecordingModel> {
|
|||||||
await _listenStatus();
|
await _listenStatus();
|
||||||
try {
|
try {
|
||||||
final status = await _initializePreviewWithRetry();
|
final status = await _initializePreviewWithRetry();
|
||||||
|
await _refreshZoomCapabilities();
|
||||||
_updateSession(
|
_updateSession(
|
||||||
(s) => s.copyWith(
|
(s) => s.copyWith(
|
||||||
status: status,
|
status: status,
|
||||||
@@ -239,6 +240,7 @@ class RecordingViewModel extends Notifier<RecordingModel> {
|
|||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
final status = await _initializePreviewWithRetry();
|
final status = await _initializePreviewWithRetry();
|
||||||
|
await _refreshZoomCapabilities();
|
||||||
_updateSession(
|
_updateSession(
|
||||||
(s) => s.copyWith(
|
(s) => s.copyWith(
|
||||||
status: status,
|
status: status,
|
||||||
@@ -309,6 +311,47 @@ class RecordingViewModel extends Notifier<RecordingModel> {
|
|||||||
return status?.isGranted == true || status?.isLimited == true;
|
return status?.isGranted == true || status?.isLimited == true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 读取相机支持的倍距范围并同步当前倍距。
|
||||||
|
Future<void> _refreshZoomCapabilities() async {
|
||||||
|
try {
|
||||||
|
final zoom = await RecordingPlatform.getZoomCapabilities();
|
||||||
|
_updateSession(
|
||||||
|
(s) => s.copyWith(
|
||||||
|
zoomRatio: zoom.zoomRatio,
|
||||||
|
minZoomRatio: zoom.minZoomRatio,
|
||||||
|
maxZoomRatio: zoom.maxZoomRatio,
|
||||||
|
errorMessage: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} on PlatformException catch (error) {
|
||||||
|
AppLogger.debug('读取相机倍距能力失败', error: error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置相机倍距,原生层会返回设备实际应用后的倍距范围与当前值。
|
||||||
|
Future<void> setZoomRatio(double ratio) async {
|
||||||
|
final session = state.session;
|
||||||
|
final clamped = ratio
|
||||||
|
.clamp(session.minZoomRatio, session.maxZoomRatio)
|
||||||
|
.toDouble();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final zoom = await RecordingPlatform.setZoomRatio(clamped);
|
||||||
|
_updateSession(
|
||||||
|
(s) => s.copyWith(
|
||||||
|
zoomRatio: zoom.zoomRatio,
|
||||||
|
minZoomRatio: zoom.minZoomRatio,
|
||||||
|
maxZoomRatio: zoom.maxZoomRatio,
|
||||||
|
errorMessage: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} on PlatformException catch (error) {
|
||||||
|
_updateSession(
|
||||||
|
(s) => s.copyWith(errorMessage: error.message ?? '相机倍距设置失败'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 开始录制,可选开启勿扰模式。
|
/// 开始录制,可选开启勿扰模式。
|
||||||
Future<void> startRecording({bool enableDoNotDisturb = true}) async {
|
Future<void> startRecording({bool enableDoNotDisturb = true}) async {
|
||||||
final session = state.session;
|
final session = state.session;
|
||||||
|
|||||||
@@ -21,11 +21,15 @@ class RecordingHudWidget extends StatelessWidget {
|
|||||||
required this.isTouchLocked,
|
required this.isTouchLocked,
|
||||||
this.showClipboardHint = false,
|
this.showClipboardHint = false,
|
||||||
this.clipboardAddress = '',
|
this.clipboardAddress = '',
|
||||||
|
required this.zoomRatio,
|
||||||
|
required this.minZoomRatio,
|
||||||
|
required this.maxZoomRatio,
|
||||||
required this.onStart,
|
required this.onStart,
|
||||||
required this.onStop,
|
required this.onStop,
|
||||||
required this.onOpenDnd,
|
required this.onOpenDnd,
|
||||||
required this.onOpenBattery,
|
required this.onOpenBattery,
|
||||||
required this.onToggleTouchLock,
|
required this.onToggleTouchLock,
|
||||||
|
required this.onZoomSelected,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
@@ -38,16 +42,21 @@ class RecordingHudWidget extends StatelessWidget {
|
|||||||
final bool isTouchLocked;
|
final bool isTouchLocked;
|
||||||
final bool showClipboardHint;
|
final bool showClipboardHint;
|
||||||
final String clipboardAddress;
|
final String clipboardAddress;
|
||||||
|
final double zoomRatio;
|
||||||
|
final double minZoomRatio;
|
||||||
|
final double maxZoomRatio;
|
||||||
final Future<void> Function() onStart;
|
final Future<void> Function() onStart;
|
||||||
final Future<void> Function() onStop;
|
final Future<void> Function() onStop;
|
||||||
final VoidCallback onOpenDnd;
|
final VoidCallback onOpenDnd;
|
||||||
final VoidCallback onOpenBattery;
|
final VoidCallback onOpenBattery;
|
||||||
final VoidCallback onToggleTouchLock;
|
final VoidCallback onToggleTouchLock;
|
||||||
|
final ValueChanged<double> onZoomSelected;
|
||||||
|
|
||||||
static double get _recordButtonSize => 70.r;
|
static double get _recordButtonSize => 70.r;
|
||||||
static double get _recordButtonBottom => 63.r;
|
static double get _recordButtonBottom => 63.r;
|
||||||
static double get _overlayInfoLeft => 13.r;
|
static double get _overlayInfoLeft => 13.r;
|
||||||
static double get _overlayInfoBottom => 10.r;
|
static double get _overlayInfoBottom => 10.r;
|
||||||
|
static const List<double> _zoomPresets = [0.6, 1.0];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -133,6 +142,18 @@ class RecordingHudWidget extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Positioned(
|
||||||
|
right: 16.r,
|
||||||
|
bottom: _recordButtonBottom + _recordButtonSize + 14.h,
|
||||||
|
child: _ZoomPresetControl(
|
||||||
|
isRecording: isRecording,
|
||||||
|
zoomRatio: zoomRatio,
|
||||||
|
minZoomRatio: minZoomRatio,
|
||||||
|
maxZoomRatio: maxZoomRatio,
|
||||||
|
presets: _zoomPresets,
|
||||||
|
onSelected: onZoomSelected,
|
||||||
|
),
|
||||||
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
@@ -171,3 +192,132 @@ class RecordingHudWidget extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ZoomPresetControl extends StatelessWidget {
|
||||||
|
const _ZoomPresetControl({
|
||||||
|
required this.isRecording,
|
||||||
|
required this.zoomRatio,
|
||||||
|
required this.minZoomRatio,
|
||||||
|
required this.maxZoomRatio,
|
||||||
|
required this.presets,
|
||||||
|
required this.onSelected,
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool isRecording;
|
||||||
|
final double zoomRatio;
|
||||||
|
final double minZoomRatio;
|
||||||
|
final double maxZoomRatio;
|
||||||
|
final List<double> presets;
|
||||||
|
final ValueChanged<double> onSelected;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final availablePresets = presets
|
||||||
|
.where(_isPresetAvailable)
|
||||||
|
.toList(growable: false);
|
||||||
|
|
||||||
|
if (availablePresets.isEmpty) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
return DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.46),
|
||||||
|
borderRadius: BorderRadius.circular(18.r),
|
||||||
|
border: Border.all(color: Colors.white.withValues(alpha: 0.18)),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(3.r),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
for (final preset in availablePresets)
|
||||||
|
_ZoomPresetButton(
|
||||||
|
displayRatio: preset,
|
||||||
|
requestRatio: preset,
|
||||||
|
selected: _isPresetSelected(preset),
|
||||||
|
enabled: !_wouldSwitchPhysicalCamera(preset),
|
||||||
|
onSelected: onSelected,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isPresetAvailable(double preset) {
|
||||||
|
if (preset < 1.0) {
|
||||||
|
return minZoomRatio <= preset && maxZoomRatio >= preset;
|
||||||
|
}
|
||||||
|
return preset >= minZoomRatio && preset <= maxZoomRatio;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isPresetSelected(double preset) {
|
||||||
|
if (preset < 1.0) {
|
||||||
|
return zoomRatio < 1.0;
|
||||||
|
}
|
||||||
|
return (zoomRatio - preset).abs() < 0.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _wouldSwitchPhysicalCamera(double preset) {
|
||||||
|
if (!isRecording) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final currentIsUltraWide = zoomRatio < 1.0;
|
||||||
|
final targetIsUltraWide = preset < 1.0;
|
||||||
|
return currentIsUltraWide != targetIsUltraWide;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ZoomPresetButton extends StatelessWidget {
|
||||||
|
const _ZoomPresetButton({
|
||||||
|
required this.displayRatio,
|
||||||
|
required this.requestRatio,
|
||||||
|
required this.selected,
|
||||||
|
required this.enabled,
|
||||||
|
required this.onSelected,
|
||||||
|
});
|
||||||
|
|
||||||
|
final double displayRatio;
|
||||||
|
final double requestRatio;
|
||||||
|
final bool selected;
|
||||||
|
final bool enabled;
|
||||||
|
final ValueChanged<double> onSelected;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 1.r),
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: selected || !enabled ? null : () => onSelected(requestRatio),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
minimumSize: Size(38.r, 32.r),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
foregroundColor: selected ? Colors.black : Colors.white,
|
||||||
|
disabledForegroundColor: Colors.black,
|
||||||
|
backgroundColor: selected ? Colors.white : Colors.transparent,
|
||||||
|
disabledBackgroundColor: Colors.white,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(15.r),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'${_formatZoomRatio(displayRatio)}x',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13.sp,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatZoomRatio(double ratio) {
|
||||||
|
if (ratio == ratio.roundToDouble()) {
|
||||||
|
return ratio.toStringAsFixed(0);
|
||||||
|
}
|
||||||
|
return ratio.toStringAsFixed(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
|||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 1.0.0+2002
|
version: 1.0.0
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.9.0
|
sdk: ^3.9.0
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
import 'package:recording_tool/features/recording/model/model_recording_session.dart';
|
||||||
|
import 'package:recording_tool/features/recording/platform/recording_channel_names.dart';
|
||||||
import 'package:recording_tool/features/recording/view-model/view_model_recording.dart';
|
import 'package:recording_tool/features/recording/view-model/view_model_recording.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@@ -24,6 +26,11 @@ void main() {
|
|||||||
tearDown(() {
|
tearDown(() {
|
||||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
.setMockMethodCallHandler(SystemChannels.platform, null);
|
.setMockMethodCallHandler(SystemChannels.platform, null);
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(
|
||||||
|
const MethodChannel(RecordingChannelNames.method),
|
||||||
|
null,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
group('RecordingViewModel', () {
|
group('RecordingViewModel', () {
|
||||||
@@ -36,9 +43,197 @@ void main() {
|
|||||||
expect(model.clipboardRecordingModel.title, defaultClipboardTitle);
|
expect(model.clipboardRecordingModel.title, defaultClipboardTitle);
|
||||||
expect(model.session.isPreviewReady, isFalse);
|
expect(model.session.isPreviewReady, isFalse);
|
||||||
expect(model.session.isRecording, isFalse);
|
expect(model.session.isRecording, isFalse);
|
||||||
|
expect(model.session.zoomRatio, 1.0);
|
||||||
|
expect(model.session.minZoomRatio, 1.0);
|
||||||
|
expect(model.session.maxZoomRatio, 3.0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('RecordingViewModel.setZoomRatio', () {
|
||||||
|
test('updates zoom ratio from native response', () async {
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(
|
||||||
|
const MethodChannel(RecordingChannelNames.method),
|
||||||
|
(call) async {
|
||||||
|
expect(call.method, 'setZoomRatio');
|
||||||
|
expect(call.arguments, <String, dynamic>{'zoomRatio': 2.0});
|
||||||
|
return <String, dynamic>{
|
||||||
|
'zoomRatio': 2.0,
|
||||||
|
'minZoomRatio': 1.0,
|
||||||
|
'maxZoomRatio': 3.0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final container = ProviderContainer();
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
await container.read(recordingViewModelProvider.notifier).setZoomRatio(2);
|
||||||
|
|
||||||
|
final session = container.read(recordingViewModelProvider).session;
|
||||||
|
expect(session.zoomRatio, 2.0);
|
||||||
|
expect(session.minZoomRatio, 1.0);
|
||||||
|
expect(session.maxZoomRatio, 3.0);
|
||||||
|
expect(session.errorMessage, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'clamps legacy 0.5x request to 0.6x ultra-wide ratio',
|
||||||
|
() async {
|
||||||
|
final calls = <MethodCall>[];
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(
|
||||||
|
const MethodChannel(RecordingChannelNames.method),
|
||||||
|
(call) async {
|
||||||
|
calls.add(call);
|
||||||
|
return <String, dynamic>{
|
||||||
|
'zoomRatio': 0.6,
|
||||||
|
'minZoomRatio': 0.6,
|
||||||
|
'maxZoomRatio': 3.0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final container = ProviderContainer();
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
final notifier = container.read(recordingViewModelProvider.notifier);
|
||||||
|
// ignore: invalid_use_of_protected_member
|
||||||
|
notifier.state = container
|
||||||
|
.read(recordingViewModelProvider)
|
||||||
|
.copyWith(
|
||||||
|
session: const RecordingSessionState(
|
||||||
|
zoomRatio: 1.0,
|
||||||
|
minZoomRatio: 0.6,
|
||||||
|
maxZoomRatio: 3.0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await notifier.setZoomRatio(0.5);
|
||||||
|
|
||||||
|
expect(calls.single.arguments, <String, dynamic>{'zoomRatio': 0.6});
|
||||||
|
final session = container.read(recordingViewModelProvider).session;
|
||||||
|
expect(session.zoomRatio, 0.6);
|
||||||
|
expect(session.minZoomRatio, 0.6);
|
||||||
|
expect(session.maxZoomRatio, 3.0);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('passes 0.6x to native when camera capabilities allow it', () async {
|
||||||
|
final calls = <MethodCall>[];
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(
|
||||||
|
const MethodChannel(RecordingChannelNames.method),
|
||||||
|
(call) async {
|
||||||
|
calls.add(call);
|
||||||
|
return <String, dynamic>{
|
||||||
|
'zoomRatio': 0.6,
|
||||||
|
'minZoomRatio': 0.6,
|
||||||
|
'maxZoomRatio': 3.0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final container = ProviderContainer();
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
final notifier = container.read(recordingViewModelProvider.notifier);
|
||||||
|
// ignore: invalid_use_of_protected_member
|
||||||
|
notifier.state = container
|
||||||
|
.read(recordingViewModelProvider)
|
||||||
|
.copyWith(
|
||||||
|
session: const RecordingSessionState(
|
||||||
|
zoomRatio: 1.0,
|
||||||
|
minZoomRatio: 0.6,
|
||||||
|
maxZoomRatio: 3.0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await notifier.setZoomRatio(0.6);
|
||||||
|
|
||||||
|
expect(calls.single.arguments, <String, dynamic>{'zoomRatio': 0.6});
|
||||||
|
final session = container.read(recordingViewModelProvider).session;
|
||||||
|
expect(session.zoomRatio, 0.6);
|
||||||
|
expect(session.minZoomRatio, 0.6);
|
||||||
|
expect(session.maxZoomRatio, 3.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clamps requested zoom ratio before invoking native', () async {
|
||||||
|
final calls = <MethodCall>[];
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(
|
||||||
|
const MethodChannel(RecordingChannelNames.method),
|
||||||
|
(call) async {
|
||||||
|
calls.add(call);
|
||||||
|
return <String, dynamic>{
|
||||||
|
'zoomRatio': 1.0,
|
||||||
|
'minZoomRatio': 1.0,
|
||||||
|
'maxZoomRatio': 1.0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final container = ProviderContainer();
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
await container.read(recordingViewModelProvider.notifier).setZoomRatio(4);
|
||||||
|
|
||||||
|
expect(calls.single.arguments, <String, dynamic>{'zoomRatio': 3.0});
|
||||||
|
expect(container.read(recordingViewModelProvider).session.zoomRatio, 1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'clamps 0.6x to 1x when camera capabilities do not allow it',
|
||||||
|
() async {
|
||||||
|
final calls = <MethodCall>[];
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(
|
||||||
|
const MethodChannel(RecordingChannelNames.method),
|
||||||
|
(call) async {
|
||||||
|
calls.add(call);
|
||||||
|
return <String, dynamic>{
|
||||||
|
'zoomRatio': 1.0,
|
||||||
|
'minZoomRatio': 1.0,
|
||||||
|
'maxZoomRatio': 3.0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final container = ProviderContainer();
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
await container
|
||||||
|
.read(recordingViewModelProvider.notifier)
|
||||||
|
.setZoomRatio(0.6);
|
||||||
|
|
||||||
|
expect(calls.single.arguments, <String, dynamic>{'zoomRatio': 1.0});
|
||||||
|
expect(
|
||||||
|
container.read(recordingViewModelProvider).session.zoomRatio,
|
||||||
|
1.0,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'keeps previous zoom ratio and stores error when native fails',
|
||||||
|
() async {
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(
|
||||||
|
const MethodChannel(RecordingChannelNames.method),
|
||||||
|
(call) async {
|
||||||
|
throw PlatformException(
|
||||||
|
code: 'ZOOM_FAILED',
|
||||||
|
message: 'Zoom is unavailable',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final container = ProviderContainer();
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
await container
|
||||||
|
.read(recordingViewModelProvider.notifier)
|
||||||
|
.setZoomRatio(2);
|
||||||
|
|
||||||
|
final session = container.read(recordingViewModelProvider).session;
|
||||||
|
expect(session.zoomRatio, 1.0);
|
||||||
|
expect(session.errorMessage, 'Zoom is unavailable');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
group('recordingGalleryPermissionsForHost', () {
|
group('recordingGalleryPermissionsForHost', () {
|
||||||
test('requests only add-only photo permission on iOS', () {
|
test('requests only add-only photo permission on iOS', () {
|
||||||
final permissions = recordingGalleryPermissionsForHost(
|
final permissions = recordingGalleryPermissionsForHost(
|
||||||
|
|||||||
166
test/features/recording/widget_recording_hud_test.dart
Normal file
166
test/features/recording/widget_recording_hud_test.dart
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:recording_tool/features/recording/widgets/widget_recording_hud.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
Future<void> pumpHud(
|
||||||
|
WidgetTester tester, {
|
||||||
|
double zoomRatio = 1.0,
|
||||||
|
double minZoomRatio = 1.0,
|
||||||
|
double maxZoomRatio = 3.0,
|
||||||
|
bool isRecording = false,
|
||||||
|
ValueChanged<double>? onZoomSelected,
|
||||||
|
}) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ScreenUtilInit(
|
||||||
|
designSize: const Size(375, 812),
|
||||||
|
builder: (context, _) {
|
||||||
|
return MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
body: RecordingHudWidget(
|
||||||
|
hasDndAccess: true,
|
||||||
|
isBatteryOptimizedIgnored: true,
|
||||||
|
notificationsGranted: true,
|
||||||
|
isRecording: isRecording,
|
||||||
|
isStartingRecording: false,
|
||||||
|
isTouchLocked: false,
|
||||||
|
zoomRatio: zoomRatio,
|
||||||
|
minZoomRatio: minZoomRatio,
|
||||||
|
maxZoomRatio: maxZoomRatio,
|
||||||
|
onStart: () async {},
|
||||||
|
onStop: () async {},
|
||||||
|
onOpenDnd: () {},
|
||||||
|
onOpenBattery: () {},
|
||||||
|
onToggleTouchLock: () {},
|
||||||
|
onZoomSelected: onZoomSelected ?? (_) {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('shows preset zoom buttons', (tester) async {
|
||||||
|
await pumpHud(tester);
|
||||||
|
|
||||||
|
expect(find.text('0.5x'), findsNothing);
|
||||||
|
expect(find.text('0.6x'), findsNothing);
|
||||||
|
expect(find.text('1x'), findsOneWidget);
|
||||||
|
expect(find.text('2x'), findsNothing);
|
||||||
|
expect(find.text('3x'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows 0.6x when ultra-wide camera capability is below 0.6', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await pumpHud(tester, minZoomRatio: 0.5);
|
||||||
|
|
||||||
|
expect(find.text('0.5x'), findsNothing);
|
||||||
|
expect(find.text('0.6x'), findsOneWidget);
|
||||||
|
expect(find.text('1x'), findsOneWidget);
|
||||||
|
expect(find.text('2x'), findsNothing);
|
||||||
|
expect(find.text('3x'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows 0.6x when 0.6x camera capability supports it', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await pumpHud(tester, minZoomRatio: 0.6);
|
||||||
|
|
||||||
|
expect(find.text('0.6x'), findsOneWidget);
|
||||||
|
expect(find.text('1x'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('marks current ultra-wide zoom ratio as selected on 0.6x UI', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await pumpHud(tester, zoomRatio: 0.5, minZoomRatio: 0.5);
|
||||||
|
|
||||||
|
final selectedButton = tester.widget<TextButton>(
|
||||||
|
find.ancestor(of: find.text('0.6x'), matching: find.byType(TextButton)),
|
||||||
|
);
|
||||||
|
expect(selectedButton.enabled, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('marks current 0.6x zoom ratio as selected', (tester) async {
|
||||||
|
await pumpHud(tester, zoomRatio: 0.6, minZoomRatio: 0.6);
|
||||||
|
|
||||||
|
final selectedButton = tester.widget<TextButton>(
|
||||||
|
find.ancestor(of: find.text('0.6x'), matching: find.byType(TextButton)),
|
||||||
|
);
|
||||||
|
expect(selectedButton.enabled, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('does not expose presets beyond max zoom ratio', (tester) async {
|
||||||
|
await pumpHud(tester, minZoomRatio: 0.5, maxZoomRatio: 0.55);
|
||||||
|
|
||||||
|
expect(find.text('0.6x'), findsNothing);
|
||||||
|
expect(find.text('1x'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('tapping 0.6x reports 0.6 when camera capability is below 0.6', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
double? selected;
|
||||||
|
await pumpHud(
|
||||||
|
tester,
|
||||||
|
minZoomRatio: 0.5,
|
||||||
|
onZoomSelected: (ratio) => selected = ratio,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.text('0.6x'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(selected, 0.6);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('tapping 0.6x reports 0.6 when camera only supports 0.6x', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
double? selected;
|
||||||
|
await pumpHud(
|
||||||
|
tester,
|
||||||
|
minZoomRatio: 0.6,
|
||||||
|
onZoomSelected: (ratio) => selected = ratio,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.text('0.6x'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(selected, 0.6);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('disables 0.6x while recording on main camera', (tester) async {
|
||||||
|
await pumpHud(tester, minZoomRatio: 0.5, isRecording: true);
|
||||||
|
|
||||||
|
final ultraWideButton = tester.widget<TextButton>(
|
||||||
|
find.ancestor(of: find.text('0.6x'), matching: find.byType(TextButton)),
|
||||||
|
);
|
||||||
|
final mainButton = tester.widget<TextButton>(
|
||||||
|
find.ancestor(of: find.text('1x'), matching: find.byType(TextButton)),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ultraWideButton.enabled, isFalse);
|
||||||
|
expect(mainButton.enabled, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('disables main zoom presets while recording on ultra-wide', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await pumpHud(tester, zoomRatio: 0.5, minZoomRatio: 0.5, isRecording: true);
|
||||||
|
|
||||||
|
final ultraWideButton = tester.widget<TextButton>(
|
||||||
|
find.ancestor(of: find.text('0.6x'), matching: find.byType(TextButton)),
|
||||||
|
);
|
||||||
|
final mainButton = tester.widget<TextButton>(
|
||||||
|
find.ancestor(of: find.text('1x'), matching: find.byType(TextButton)),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ultraWideButton.enabled, isFalse);
|
||||||
|
expect(mainButton.enabled, isFalse);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user