2 Commits

Author SHA1 Message Date
1b404525d2 1.更换包名
2.调整录制页地址下方时间为当前时间读秒
2026-06-04 16:58:34 +08:00
77d9c35592 1.确定 APP 包名
2.录制结束增加弹窗提示
3.完成读取剪切板内容、新增粘贴剪切板内容
2026-06-04 16:25:26 +08:00
30 changed files with 911 additions and 633 deletions

View File

@@ -4,7 +4,7 @@ plugins {
id("dev.flutter.flutter-gradle-plugin")
}
val appPackageName = "com.gdfw.fxjk"
val appPackageName = "com.qxy.dronex"
android {
namespace = appPackageName

View File

@@ -1,5 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.gdfw.fxjk">
package="com.qxy.dronex">
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
@@ -19,7 +19,7 @@
android:required="true" />
<application
android:label="飞行极控"
android:label="飞行极控录像工作台"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
@@ -32,12 +32,12 @@
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
@@ -52,8 +52,8 @@
</application>
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
<action android:name="android.intent.action.PROCESS_TEXT" />
<data android:mimeType="text/plain" />
</intent>
</queries>
</manifest>
</manifest>

View File

@@ -1,120 +0,0 @@
package com.gdfw.fxjk
import android.content.pm.ApplicationInfo
import android.os.Build
import androidx.camera.view.PreviewView
import com.gdfw.fxjk.recording.RecordingPlatformHandler
import com.gdfw.fxjk.recording.RecordingPreviewFactory
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() {
private var platformHandler: RecordingPlatformHandler? = null
private var platformInfoChannel: MethodChannel? = null
var recordingPreviewView: PreviewView? = null
private set
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
flutterEngine
.platformViewsController
.registry
.registerViewFactory(
"recording-camera-preview",
RecordingPreviewFactory(this),
)
platformInfoChannel =
MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
AppConstants.PLATFORM_INFO_CHANNEL,
).also { channel ->
channel.setMethodCallHandler { call, result ->
when (call.method) {
"packageInfo" -> result.success(packageInfoMap())
"deviceInfo" -> result.success(deviceInfoMap())
else -> result.notImplemented()
}
}
}
platformHandler =
RecordingPlatformHandler(
this,
flutterEngine.dartExecutor.binaryMessenger,
)
}
fun attachRecordingPreview(previewView: PreviewView) {
recordingPreviewView = previewView
}
fun detachRecordingPreview(previewView: PreviewView? = null) {
if (previewView == null || recordingPreviewView === previewView) {
recordingPreviewView = null
}
}
override fun onDestroy() {
platformInfoChannel?.setMethodCallHandler(null)
platformInfoChannel = null
platformHandler?.dispose()
platformHandler = null
super.onDestroy()
}
private fun packageInfoMap(): Map<String, String> {
val packageInfo =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packageManager.getPackageInfo(
packageName,
android.content.pm.PackageManager.PackageInfoFlags.of(0),
)
} else {
@Suppress("DEPRECATION")
packageManager.getPackageInfo(packageName, 0)
}
val appName =
applicationInfo.loadLabel(packageManager)?.toString().orEmpty()
val versionCode =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
packageInfo.longVersionCode.toString()
} else {
@Suppress("DEPRECATION")
packageInfo.versionCode.toString()
}
return mapOf(
"appName" to appName,
"packageName" to packageName,
"version" to packageInfo.versionName.orEmpty(),
"buildNumber" to versionCode,
)
}
private fun deviceInfoMap(): Map<String, Any> {
val flags = applicationInfo.flags
val isEmulator =
Build.FINGERPRINT.startsWith("generic") ||
Build.FINGERPRINT.startsWith("unknown") ||
Build.MODEL.contains("google_sdk") ||
Build.MODEL.contains("Emulator") ||
Build.MODEL.contains("Android SDK built for x86") ||
Build.MANUFACTURER.contains("Genymotion") ||
Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic") ||
Build.PRODUCT == "google_sdk" ||
flags and ApplicationInfo.FLAG_DEBUGGABLE != 0 &&
Build.HARDWARE.contains("ranchu")
return mapOf(
"platform" to "android",
"brand" to Build.BRAND,
"model" to Build.MODEL,
"systemVersion" to Build.VERSION.RELEASE,
"isPhysicalDevice" to !isEmulator,
)
}
}

View File

@@ -1,236 +0,0 @@
package com.gdfw.fxjk.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
}
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"
}
}

View File

@@ -1,24 +0,0 @@
package com.gdfw.fxjk.recording
enum class RecordingState {
IDLE,
PREVIEWING,
RECORDING,
STOPPING,
ERROR,
}
data class RecordingStatus(
val state: RecordingState,
val outputPath: String? = null,
val elapsedMillis: Long = 0L,
val message: String? = null,
) {
fun toMap(): Map<String, Any?> =
mapOf(
"state" to state.name.lowercase(),
"outputPath" to outputPath,
"elapsedMillis" to elapsedMillis,
"message" to message,
)
}

View File

@@ -1,7 +1,7 @@
package com.gdfw.fxjk
package com.qxy.dronex
object AppConstants {
const val PACKAGE_NAME = "com.gdfw.fxjk"
const val PACKAGE_NAME = "com.qxy.dronex"
const val PLATFORM_INFO_CHANNEL = "$PACKAGE_NAME/platform_info"
const val RECORDING_METHOD_CHANNEL = "$PACKAGE_NAME/recording"
const val RECORDING_EVENT_CHANNEL = "$PACKAGE_NAME/recording_events"

View File

@@ -0,0 +1,115 @@
package com.qxy.dronex
import android.content.pm.ApplicationInfo
import android.os.Build
import androidx.camera.view.PreviewView
import com.qxy.dronex.recording.RecordingPlatformHandler
import com.qxy.dronex.recording.RecordingPreviewFactory
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() {
private var platformHandler: RecordingPlatformHandler? = null
private var platformInfoChannel: MethodChannel? = null
var recordingPreviewView: PreviewView? = null
private set
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
flutterEngine.platformViewsController.registry.registerViewFactory(
"recording-camera-preview",
RecordingPreviewFactory(this),
)
platformInfoChannel =
MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
AppConstants.PLATFORM_INFO_CHANNEL,
)
.also { channel ->
channel.setMethodCallHandler { call, result ->
when (call.method) {
"packageInfo" -> result.success(packageInfoMap())
"deviceInfo" -> result.success(deviceInfoMap())
else -> result.notImplemented()
}
}
}
platformHandler =
RecordingPlatformHandler(
this,
flutterEngine.dartExecutor.binaryMessenger,
)
}
fun attachRecordingPreview(previewView: PreviewView) {
recordingPreviewView = previewView
}
fun detachRecordingPreview(previewView: PreviewView? = null) {
if (previewView == null || recordingPreviewView === previewView) {
recordingPreviewView = null
}
}
override fun onDestroy() {
platformInfoChannel?.setMethodCallHandler(null)
platformInfoChannel = null
platformHandler?.dispose()
platformHandler = null
super.onDestroy()
}
private fun packageInfoMap(): Map<String, String> {
val packageInfo =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packageManager.getPackageInfo(
packageName,
android.content.pm.PackageManager.PackageInfoFlags.of(0),
)
} else {
@Suppress("DEPRECATION") packageManager.getPackageInfo(packageName, 0)
}
val appName = applicationInfo.loadLabel(packageManager)?.toString().orEmpty()
val versionCode =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
packageInfo.longVersionCode.toString()
} else {
@Suppress("DEPRECATION") packageInfo.versionCode.toString()
}
return mapOf(
"appName" to appName,
"packageName" to packageName,
"version" to packageInfo.versionName.orEmpty(),
"buildNumber" to versionCode,
)
}
private fun deviceInfoMap(): Map<String, Any> {
val flags = applicationInfo.flags
val isEmulator =
Build.FINGERPRINT.startsWith("generic") ||
Build.FINGERPRINT.startsWith("unknown") ||
Build.MODEL.contains("google_sdk") ||
Build.MODEL.contains("Emulator") ||
Build.MODEL.contains("Android SDK built for x86") ||
Build.MANUFACTURER.contains("Genymotion") ||
Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic") ||
Build.PRODUCT == "google_sdk" ||
flags and ApplicationInfo.FLAG_DEBUGGABLE != 0 &&
Build.HARDWARE.contains("ranchu")
return mapOf(
"platform" to "android",
"brand" to Build.BRAND,
"model" to Build.MODEL,
"systemVersion" to Build.VERSION.RELEASE,
"isPhysicalDevice" to !isEmulator,
)
}
}

View File

@@ -1,4 +1,4 @@
package com.gdfw.fxjk.recording
package com.qxy.dronex.recording
import android.content.Context
import android.content.Intent
@@ -18,10 +18,10 @@ object BatteryOptimizationHelper {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return
val intent =
Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
if (intent.resolveActivity(context.packageManager) != null) {
context.startActivity(intent)
@@ -29,9 +29,9 @@ object BatteryOptimizationHelper {
}
val fallback =
Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(fallback)
}
}

View File

@@ -1,9 +1,8 @@
package com.gdfw.fxjk.recording
package com.qxy.dronex.recording
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.os.Build
import android.provider.Settings
import androidx.core.app.NotificationManagerCompat
@@ -16,9 +15,10 @@ object DoNotDisturbHelper {
}
fun openAccessSettings(context: Context) {
val intent = Intent(Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
val intent =
Intent(Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}

View File

@@ -0,0 +1,239 @@
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
}
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"
}
}

View File

@@ -1,22 +1,21 @@
package com.gdfw.fxjk.recording
package com.qxy.dronex.recording
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.os.Build
import androidx.core.content.ContextCompat
import android.os.IBinder
import android.os.PowerManager
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleService
import com.gdfw.fxjk.AppConstants
import com.gdfw.fxjk.MainActivity
import com.qxy.dronex.AppConstants
import com.qxy.dronex.MainActivity
class RecordingForegroundService : LifecycleService() {
private var wakeLock: PowerManager.WakeLock? = null
@@ -35,9 +34,9 @@ class RecordingForegroundService : LifecycleService() {
val notification = buildNotification("正在录制")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
NOTIFICATION_ID,
notification,
foregroundServiceTypes(),
NOTIFICATION_ID,
notification,
foregroundServiceTypes(),
)
} else {
startForeground(NOTIFICATION_ID, notification)
@@ -72,10 +71,10 @@ class RecordingForegroundService : LifecycleService() {
if (wakeLock?.isHeld == true) return
val manager = getSystemService(PowerManager::class.java) ?: return
wakeLock =
manager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).apply {
setReferenceCounted(false)
acquire(4 * 60 * 60 * 1000L)
}
manager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).apply {
setReferenceCounted(false)
acquire(4 * 60 * 60 * 1000L)
}
}
private fun releaseWakeLock() {
@@ -90,14 +89,15 @@ class RecordingForegroundService : LifecycleService() {
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val channel =
NotificationChannel(
CHANNEL_ID,
"录制服务",
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "保持相机录制在后台与息屏时继续运行"
setShowBadge(false)
}
NotificationChannel(
CHANNEL_ID,
"录制服务",
NotificationManager.IMPORTANCE_LOW,
)
.apply {
description = "保持相机录制在后台与息屏时继续运行"
setShowBadge(false)
}
val manager = getSystemService(NotificationManager::class.java)
manager?.createNotificationChannel(channel)
}
@@ -112,33 +112,33 @@ class RecordingForegroundService : LifecycleService() {
private fun hasRecordAudioPermission(): Boolean {
return ContextCompat.checkSelfPermission(
this,
android.Manifest.permission.RECORD_AUDIO,
this,
android.Manifest.permission.RECORD_AUDIO,
) == PackageManager.PERMISSION_GRANTED
}
private fun buildNotification(content: String): Notification {
val launchIntent =
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
}
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
}
val pendingIntent =
PendingIntent.getActivity(
this,
0,
launchIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
PendingIntent.getActivity(
this,
0,
launchIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("录制进行中")
.setContentText(content)
.setSmallIcon(android.R.drawable.presence_video_online)
.setOngoing(true)
.setContentIntent(pendingIntent)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
.setContentTitle("录制进行中")
.setContentText(content)
.setSmallIcon(android.R.drawable.presence_video_online)
.setOngoing(true)
.setContentIntent(pendingIntent)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
}
companion object {
@@ -146,25 +146,23 @@ class RecordingForegroundService : LifecycleService() {
const val NOTIFICATION_ID = 1001
private const val WAKE_LOCK_TAG = "record_tool:recording_wake_lock"
@Volatile
var isRunning: Boolean = false
@Volatile var isRunning: Boolean = false
@Volatile
var instance: RecordingForegroundService? = null
@Volatile var instance: RecordingForegroundService? = null
fun start(context: Context) {
val intent =
Intent(context, RecordingForegroundService::class.java).apply {
action = AppConstants.RECORDING_ACTION_START
}
Intent(context, RecordingForegroundService::class.java).apply {
action = AppConstants.RECORDING_ACTION_START
}
ContextCompatStart.startForegroundService(context, intent)
}
fun stop(context: Context) {
val intent =
Intent(context, RecordingForegroundService::class.java).apply {
action = AppConstants.RECORDING_ACTION_STOP
}
Intent(context, RecordingForegroundService::class.java).apply {
action = AppConstants.RECORDING_ACTION_STOP
}
context.startService(intent)
}
}

View File

@@ -1,4 +1,4 @@
package com.gdfw.fxjk.recording
package com.qxy.dronex.recording
import android.content.ContentValues
import android.content.Context
@@ -10,29 +10,29 @@ import java.util.Date
import java.util.Locale
object RecordingOutputFactory {
private const val RELATIVE_PATH = "Movies/飞行极控"
private const val RELATIVE_PATH = "Movies/飞行极控录像工作台"
private const val MIME_TYPE = "video/mp4"
fun buildMediaStoreOutputOptions(
context: Context,
displayName: String?,
context: Context,
displayName: String?,
): MediaStoreOutputOptions {
val fileName = resolveFileName(displayName)
val contentValues =
ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
put(MediaStore.MediaColumns.MIME_TYPE, MIME_TYPE)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(MediaStore.Video.Media.RELATIVE_PATH, RELATIVE_PATH)
ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
put(MediaStore.MediaColumns.MIME_TYPE, MIME_TYPE)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(MediaStore.Video.Media.RELATIVE_PATH, RELATIVE_PATH)
}
}
}
return MediaStoreOutputOptions.Builder(
context.contentResolver,
MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
)
.setContentValues(contentValues)
.build()
context.contentResolver,
MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
)
.setContentValues(contentValues)
.build()
}
fun resolveFileName(displayName: String?): String {
@@ -44,8 +44,7 @@ object RecordingOutputFactory {
"$trimmed.mp4"
}
}
val timestamp =
SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
return "REC_$timestamp.mp4"
}
}

View File

@@ -1,27 +1,23 @@
package com.gdfw.fxjk.recording
package com.qxy.dronex.recording
import android.app.Activity
import android.os.Build
import android.os.Handler
import android.os.Looper
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import com.gdfw.fxjk.AppConstants
import com.gdfw.fxjk.MainActivity
import com.qxy.dronex.AppConstants
import com.qxy.dronex.MainActivity
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
class RecordingPlatformHandler(
private val activity: MainActivity,
messenger: BinaryMessenger,
private val activity: MainActivity,
messenger: BinaryMessenger,
) : MethodChannel.MethodCallHandler, EventChannel.StreamHandler {
private val methodChannel =
MethodChannel(messenger, AppConstants.RECORDING_METHOD_CHANNEL)
private val eventChannel =
EventChannel(messenger, AppConstants.RECORDING_EVENT_CHANNEL)
private val methodChannel = MethodChannel(messenger, AppConstants.RECORDING_METHOD_CHANNEL)
private val eventChannel = EventChannel(messenger, AppConstants.RECORDING_EVENT_CHANNEL)
private val mainHandler = Handler(Looper.getMainLooper())
private var eventSink: EventChannel.EventSink? = null
@@ -33,9 +29,7 @@ class RecordingPlatformHandler(
methodChannel.setMethodCallHandler(this)
eventChannel.setStreamHandler(this)
controller.statusListener = { status ->
mainHandler.post {
eventSink?.success(status.toMap())
}
mainHandler.post { eventSink?.success(status.toMap()) }
}
}
@@ -60,20 +54,18 @@ class RecordingPlatformHandler(
controller.unbind()
result.success(null)
}
"hasNotificationPolicyAccess" ->
result.success(DoNotDisturbHelper.hasAccess(activity))
"hasNotificationPolicyAccess" -> result.success(DoNotDisturbHelper.hasAccess(activity))
"openNotificationPolicySettings" -> {
DoNotDisturbHelper.openAccessSettings(activity)
result.success(null)
}
"enableDoNotDisturb" ->
result.success(DoNotDisturbHelper.enable(activity))
"enableDoNotDisturb" -> result.success(DoNotDisturbHelper.enable(activity))
"disableDoNotDisturb" -> {
DoNotDisturbHelper.disable(activity)
result.success(null)
}
"isIgnoringBatteryOptimizations" ->
result.success(BatteryOptimizationHelper.isIgnoringOptimizations(activity))
result.success(BatteryOptimizationHelper.isIgnoringOptimizations(activity))
"openBatteryOptimizationSettings" -> {
BatteryOptimizationHelper.openSettings(activity)
result.success(null)
@@ -84,8 +76,7 @@ class RecordingPlatformHandler(
result.success(null)
}
"getStatus" -> result.success(controller.status.toMap())
"isForegroundServiceRunning" ->
result.success(RecordingForegroundService.isRunning)
"isForegroundServiceRunning" -> result.success(RecordingForegroundService.isRunning)
else -> result.notImplemented()
}
}
@@ -109,10 +100,10 @@ class RecordingPlatformHandler(
}
private fun startRecording(
withAudio: Boolean,
enableDnd: Boolean,
displayName: String?,
result: MethodChannel.Result,
withAudio: Boolean,
enableDnd: Boolean,
displayName: String?,
result: MethodChannel.Result,
) {
val previewView = activity.recordingPreviewView
if (previewView == null) {
@@ -132,10 +123,10 @@ class RecordingPlatformHandler(
if (started) {
startElapsedTicker()
result.success(
mapOf(
"outputPath" to message,
"status" to controller.status.toMap(),
),
mapOf(
"outputPath" to message,
"status" to controller.status.toMap(),
),
)
} else {
RecordingSession.stopForeground(activity)
@@ -147,8 +138,7 @@ class RecordingPlatformHandler(
}
fun rebindAndCapture() {
val lifecycleOwner =
RecordingForegroundService.instance ?: activity
val lifecycleOwner = RecordingForegroundService.instance ?: activity
controller.rebindForRecording(lifecycleOwner, previewView) { ready ->
if (ready) {
beginCapture()
@@ -172,17 +162,15 @@ class RecordingPlatformHandler(
RecordingSession.stopForeground(activity)
DoNotDisturbHelper.disable(activity)
mainHandler.post {
val gallerySaved =
path != null &&
controller.status.state != RecordingState.ERROR
val payload = mutableMapOf<String, Any?>(
"outputPath" to path,
"status" to controller.status.toMap(),
"gallerySaved" to gallerySaved,
)
val gallerySaved = path != null && controller.status.state != RecordingState.ERROR
val payload =
mutableMapOf<String, Any?>(
"outputPath" to path,
"status" to controller.status.toMap(),
"gallerySaved" to gallerySaved,
)
if (!gallerySaved) {
payload["galleryErrorMessage"] =
controller.status.message ?: "保存到相册失败"
payload["galleryErrorMessage"] = controller.status.message ?: "保存到相册失败"
}
result.success(payload)
}
@@ -196,7 +184,7 @@ class RecordingPlatformHandler(
if (enabled) {
insetsController.hide(WindowInsetsCompat.Type.systemBars())
insetsController.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
} else {
insetsController.show(WindowInsetsCompat.Type.systemBars())
}
@@ -205,20 +193,23 @@ class RecordingPlatformHandler(
private fun startElapsedTicker() {
stopElapsedTicker()
elapsedTicker =
object : Runnable {
override fun run() {
if (controller.status.state == RecordingState.RECORDING) {
eventSink?.success(
controller.status.copy(
elapsedMillis = controller.elapsedMillis(),
).toMap(),
)
mainHandler.postDelayed(this, 1000L)
}
}
}.also {
mainHandler.post(it)
}
object : Runnable {
override fun run() {
if (controller.status.state == RecordingState.RECORDING) {
eventSink?.success(
controller
.status
.copy(
elapsedMillis =
controller.elapsedMillis(),
)
.toMap(),
)
mainHandler.postDelayed(this, 1000L)
}
}
}
.also { mainHandler.post(it) }
}
private fun stopElapsedTicker() {

View File

@@ -1,15 +1,15 @@
package com.gdfw.fxjk.recording
package com.qxy.dronex.recording
import android.content.Context
import android.view.View
import androidx.camera.view.PreviewView
import com.gdfw.fxjk.MainActivity
import com.qxy.dronex.MainActivity
import io.flutter.plugin.common.StandardMessageCodec
import io.flutter.plugin.platform.PlatformView
import io.flutter.plugin.platform.PlatformViewFactory
class RecordingPreviewFactory(
private val activity: MainActivity,
private val activity: MainActivity,
) : PlatformViewFactory(StandardMessageCodec.INSTANCE) {
override fun create(context: Context, viewId: Int, args: Any?): PlatformView {
return RecordingPreviewPlatformView(activity)
@@ -17,13 +17,13 @@ class RecordingPreviewFactory(
}
class RecordingPreviewPlatformView(
private val activity: MainActivity,
private val activity: MainActivity,
) : PlatformView {
val previewView: PreviewView =
PreviewView(activity).apply {
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
scaleType = PreviewView.ScaleType.FILL_CENTER
}
PreviewView(activity).apply {
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
scaleType = PreviewView.ScaleType.FILL_CENTER
}
init {
activity.attachRecordingPreview(previewView)

View File

@@ -1,4 +1,4 @@
package com.gdfw.fxjk.recording
package com.qxy.dronex.recording
import android.content.Context
import androidx.lifecycle.LifecycleService
@@ -8,9 +8,9 @@ object RecordingSession {
fun controller(context: Context): RecordingCameraController {
return cameraController
?: RecordingCameraController(context.applicationContext).also {
cameraController = it
}
?: RecordingCameraController(context.applicationContext).also {
cameraController = it
}
}
fun release() {

View File

@@ -0,0 +1,24 @@
package com.qxy.dronex.recording
enum class RecordingState {
IDLE,
PREVIEWING,
RECORDING,
STOPPING,
ERROR,
}
data class RecordingStatus(
val state: RecordingState,
val outputPath: String? = null,
val elapsedMillis: Long = 0L,
val message: String? = null,
) {
fun toMap(): Map<String, Any?> =
mapOf(
"state" to state.name.lowercase(),
"outputPath" to outputPath,
"elapsedMillis" to elapsedMillis,
"message" to message,
)
}

View File

@@ -1,22 +1,16 @@
PODS:
- Flutter (1.0.0)
- permission_handler_apple (9.3.0):
- Flutter
DEPENDENCIES:
- Flutter (from `Flutter`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
EXTERNAL SOURCES:
Flutter:
:path: Flutter
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
SPEC CHECKSUMS:
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
PODFILE CHECKSUM: 6f58d96c1fb09d1d57dbcbf38a5d856d7302054a
PODFILE CHECKSUM: 5a82b772179df87e6518bddc6f9bb8b4053ce48b
COCOAPODS: 1.16.2

View File

@@ -13,8 +13,9 @@
64BC3B3C75FABAA128C4FE7A /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D84231833EC084C45F76D9A0 /* Pods_RunnerTests.framework */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
8D5D9D9E2C69000100F10001 /* RecordingPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D5D9D9D2C69000100F10001 /* RecordingPlugin.swift */; };
87DA76CA5231F693C9392B80 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B6AA1C86CE710449CC3E8FCD /* Pods_Runner.framework */; };
8D5D9D9E2C69000100F10001 /* RecordingPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D5D9D9D2C69000100F10001 /* RecordingPlugin.swift */; };
8E5D9D9E2C69000100F10002 /* PlatformInfoPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E5D9D9D2C69000100F10002 /* PlatformInfoPlugin.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
@@ -54,8 +55,9 @@
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
8D5D9D9D2C69000100F10001 /* RecordingPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingPlugin.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
8D5D9D9D2C69000100F10001 /* RecordingPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingPlugin.swift; sourceTree = "<group>"; };
8E5D9D9D2C69000100F10002 /* PlatformInfoPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformInfoPlugin.swift; sourceTree = "<group>"; };
95C6AC1536EA3DBA2D369C55 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
@@ -167,6 +169,7 @@
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
8D5D9D9D2C69000100F10001 /* RecordingPlugin.swift */,
8E5D9D9D2C69000100F10002 /* PlatformInfoPlugin.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
@@ -205,7 +208,6 @@
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
62D3774184B6C76B8E34C8C2 /* [CP] Copy Pods Resources */,
);
buildRules = (
);
@@ -343,23 +345,6 @@
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
62D3774184B6C76B8E34C8C2 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
showEnvVarsInLog = 0;
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
@@ -392,6 +377,7 @@
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
8D5D9D9E2C69000100F10001 /* RecordingPlugin.swift in Sources */,
8E5D9D9E2C69000100F10002 /* PlatformInfoPlugin.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -492,7 +478,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterTemplate;
PRODUCT_BUNDLE_IDENTIFIER = com.qxy.dronex;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
@@ -509,7 +495,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterTemplate.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.qxy.dronex.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -527,7 +513,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterTemplate.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.qxy.dronex.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -543,7 +529,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterTemplate.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.qxy.dronex.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -675,7 +661,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterTemplate;
PRODUCT_BUNDLE_IDENTIFIER = com.qxy.dronex;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -698,7 +684,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterTemplate;
PRODUCT_BUNDLE_IDENTIFIER = com.qxy.dronex;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;

View File

@@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>飞行极控</string>
<string>飞行极控录像工作台</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@@ -15,7 +15,7 @@
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>飞行极控</string>
<string>飞行极控录像工作台</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>

View File

@@ -4,7 +4,7 @@ import UIKit
final class PlatformInfoPlugin: NSObject, FlutterPlugin {
static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(
name: "com.gdfw.fxjk/platform_info",
name: "com.qxy.dronex/platform_info",
binaryMessenger: registrar.messenger()
)
let plugin = PlatformInfoPlugin()

View File

@@ -142,7 +142,8 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
func initializePreview(result: @escaping FlutterResult) {
guard let previewView else {
result(FlutterError(code: "NO_PREVIEW", message: "Camera preview is not attached", details: nil))
result(
FlutterError(code: "NO_PREVIEW", message: "Camera preview is not attached", details: nil))
return
}
@@ -176,7 +177,8 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
func startRecording(withAudio: Bool, displayName: String?, result: @escaping FlutterResult) {
guard previewView != nil else {
result(FlutterError(code: "NO_PREVIEW", message: "Camera preview is not attached", details: nil))
result(
FlutterError(code: "NO_PREVIEW", message: "Camera preview is not attached", details: nil))
return
}
@@ -306,7 +308,9 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
if let error {
latestGallerySaved = false
latestGalleryErrorMessage = error.localizedDescription
updateStatus(RecordingStatus(state: .error, outputPath: latestOutputPath, message: error.localizedDescription))
updateStatus(
RecordingStatus(
state: .error, outputPath: latestOutputPath, message: error.localizedDescription))
finishStopRecording(stopResult: stopResult)
return
}
@@ -411,10 +415,14 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
return
}
guard let videoDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back)
?? AVCaptureDevice.default(for: .video)
guard
let videoDevice = AVCaptureDevice.default(
.builtInWideAngleCamera, for: .video, position: .back)
?? AVCaptureDevice.default(for: .video)
else {
throw NSError(domain: "RecordingCamera", code: 1, userInfo: [NSLocalizedDescriptionKey: "No camera device available"])
throw NSError(
domain: "RecordingCamera", code: 1,
userInfo: [NSLocalizedDescriptionKey: "No camera device available"])
}
let nextVideoInput = try AVCaptureDeviceInput(device: videoDevice)
@@ -424,14 +432,18 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
guard session.canAddInput(nextVideoInput) else {
session.commitConfiguration()
throw NSError(domain: "RecordingCamera", code: 2, userInfo: [NSLocalizedDescriptionKey: "Cannot add camera input"])
throw NSError(
domain: "RecordingCamera", code: 2,
userInfo: [NSLocalizedDescriptionKey: "Cannot add camera input"])
}
session.addInput(nextVideoInput)
videoInput = nextVideoInput
guard session.canAddOutput(movieOutput) else {
session.commitConfiguration()
throw NSError(domain: "RecordingCamera", code: 3, userInfo: [NSLocalizedDescriptionKey: "Cannot add movie output"])
throw NSError(
domain: "RecordingCamera", code: 3,
userInfo: [NSLocalizedDescriptionKey: "Cannot add movie output"])
}
session.addOutput(movieOutput)
session.commitConfiguration()
@@ -516,7 +528,7 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
}
private enum RecordingChannelNames {
static let packageName = "com.gdfw.fxjk"
static let packageName = "com.qxy.dronex"
static let method = "\(packageName)/recording"
static let events = "\(packageName)/recording_events"
}
@@ -584,7 +596,9 @@ final class RecordingPlugin: NSObject, FlutterPlugin, FlutterStreamHandler {
}
}
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink)
-> FlutterError?
{
eventSink = events
events(controller.currentStatusMap())
return nil

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -18,12 +20,31 @@ class AppBootstrapper {
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
await AppStorage.init();
final packageInfo = await AppPlatformInfo.packageInfo();
AppConfig.configure(environment: environment, packageInfo: packageInfo);
AppConfig.configure(environment: environment);
AppLogger.debug('App started in ${AppConfig.current.environment.name}');
runApp(const ProviderScope(child: FlutterTemplateApp()));
// Load native package metadata after the first frame can render.
// Awaiting MethodChannel calls before runApp() can stall the Android
// splash screen on some devices.
unawaited(_loadPackageInfo(environment));
}
static Future<void> _loadPackageInfo(AppEnvironment environment) async {
try {
final packageInfo = await AppPlatformInfo.packageInfo().timeout(
const Duration(seconds: 8),
);
AppConfig.configure(environment: environment, packageInfo: packageInfo);
} catch (error, stackTrace) {
AppLogger.debug(
'Native packageInfo unavailable',
error: error,
stackTrace: stackTrace,
);
}
}
}

View File

@@ -21,7 +21,7 @@ class AppConfig {
static late EnvironmentValues current;
static AppPackageInfo? packageInfo;
static const appName = '飞行极控';
static const appName = '飞行极控录像工作台';
static const designSize = Size(375, 812);
static void configure({

View File

@@ -59,7 +59,7 @@ class AppPlatformInfo {
AppPlatformInfo._();
static const MethodChannel _channel = MethodChannel(
'com.gdfw.fxjk/platform_info',
'com.qxy.dronex/platform_info',
);
static Future<AppPackageInfo> packageInfo() async {

View File

@@ -1,8 +1,8 @@
/// 小程序复制到剪切板的录制信息。
class ClipboardRecordingModel {
final String title;
final int startTimestamp;
final int endTimestamp;
int? startTimestamp;
int? endTimestamp;
final String address;
/// 录制文件名模板如「选手名称_选手ID_赛事名称_赛项」。
@@ -10,8 +10,8 @@ class ClipboardRecordingModel {
ClipboardRecordingModel({
required this.title,
required this.startTimestamp,
required this.endTimestamp,
this.startTimestamp,
this.endTimestamp,
required this.address,
this.filename,
});

View File

@@ -1,5 +1,5 @@
abstract final class RecordingChannelNames {
static const packageName = 'com.gdfw.fxjk';
static const packageName = 'com.qxy.dronex';
static const method = '$packageName/recording';
static const events = '$packageName/recording_events';
}

View File

@@ -1,14 +1,19 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:recording_tool/core/utils/date_time_formatter.dart';
import 'package:recording_tool/features/recording/model/model_recording.dart';
import 'package:recording_tool/features/recording/recording_display_name.dart';
import 'package:recording_tool/features/recording/recording_platform.dart';
import 'package:recording_tool/features/recording/recording_session_controller.dart';
import 'package:recording_tool/features/recording/view-model/view_model_recording.dart';
import 'package:recording_tool/features/recording/widgets/camera_preview_widget.dart';
import 'package:recording_tool/features/recording/widgets/recording_saved_dialog.dart';
import 'package:recording_tool/features/recording/widgets/recording_touch_lock_overlay.dart';
import 'package:recording_tool/shared/widgets/widgets.dart';
@@ -52,6 +57,50 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
_immersiveApplied = true;
}
String _savedDialogSessionTitle(
RecordingModel recordingInfo,
String? savedName,
) {
final clipboard = recordingInfo.clipboardRecordingModel;
if (recordingInfo.hasValidClipboardInfo &&
clipboard.title.trim().isNotEmpty) {
return clipboard.title.trim();
}
if (savedName != null && savedName.isNotEmpty) {
return resolveRecordingDisplayName(savedName);
}
return '录制完成';
}
Future<void> _showRecordingSavedDialogIfNeeded() async {
final session = ref.read(recordingSessionControllerProvider);
if (session.lastSavedDisplayName == null || session.gallerySaveFailed) {
return;
}
final recordingInfo = ref.read(recordingViewModelProvider);
final sessionTitle = _savedDialogSessionTitle(
recordingInfo,
session.lastSavedDisplayName,
);
await showRecordingSavedDialog(
context,
sessionTitle: sessionTitle,
onContinueRound: () {
ref
.read(recordingSessionControllerProvider.notifier)
.clearSavedRecordingResult();
},
onRecordNewRound: () {
ref.read(recordingViewModelProvider.notifier).resetClipboardInfo();
ref
.read(recordingSessionControllerProvider.notifier)
.clearSavedRecordingResult();
},
);
}
Future<void> _exitRecordingMode() async {
if (!_immersiveApplied) return;
await ref.read(recordingSessionControllerProvider.notifier).teardown();
@@ -100,6 +149,8 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
fit: StackFit.expand,
children: [
const CameraPreviewWidget(),
if (!state.isPreviewReady && state.errorMessage == null)
const _RecordingLoadingOverlay(message: '正在启动相机…'),
if (state.isTouchLocked && state.isRecording)
RecordingTouchLockOverlay(
enabled: true,
@@ -109,6 +160,8 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
state: state,
eventTitle: showClipboardInfo ? clipboard.title : null,
eventAddress: showClipboardInfo ? clipboard.address : null,
showClipboardHint: showClipboardInfo,
clipboardAddress: clipboard.address.trim(),
onPasteEventInfo: () async {
final result = await ref
.read(recordingViewModelProvider.notifier)
@@ -125,7 +178,9 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
final latest = ref.read(recordingSessionControllerProvider);
if (latest.gallerySaveFailed) {
AppToast.show(latest.errorMessage ?? '保存到相册失败,请开启相册权限');
return;
}
await _showRecordingSavedDialogIfNeeded();
},
onOpenDnd: () async {
await controller.openDndSettings();
@@ -139,6 +194,40 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
controller.setTouchLocked(!state.isTouchLocked);
},
),
if (state.isStartingRecording)
const _RecordingLoadingOverlay(message: '正在开始录制…'),
],
),
),
);
}
}
class _RecordingLoadingOverlay extends StatelessWidget {
const _RecordingLoadingOverlay({required this.message});
final String message;
@override
Widget build(BuildContext context) {
return ColoredBox(
color: Colors.black,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox.square(
dimension: 32.r,
child: CircularProgressIndicator(
strokeWidth: 2.5.r,
color: Colors.white70,
),
),
SizedBox(height: 14.h),
Text(
message,
style: TextStyle(color: Colors.white70, fontSize: 14.sp),
),
],
),
),
@@ -151,6 +240,8 @@ class _RecordingHud extends StatelessWidget {
required this.state,
this.eventTitle,
this.eventAddress,
this.showClipboardHint = false,
this.clipboardAddress = '',
required this.onPasteEventInfo,
required this.onStart,
required this.onStop,
@@ -162,6 +253,8 @@ class _RecordingHud extends StatelessWidget {
final RecordingSessionState state;
final String? eventTitle;
final String? eventAddress;
final bool showClipboardHint;
final String clipboardAddress;
final Future<void> Function() onPasteEventInfo;
final VoidCallback onStart;
final VoidCallback onStop;
@@ -222,6 +315,8 @@ class _RecordingHud extends StatelessWidget {
hasDndAccess: state.hasDndAccess,
isBatteryIgnored: state.isBatteryOptimizedIgnored,
notificationsGranted: state.notificationsGranted,
showClipboardHint: showClipboardHint,
clipboardAddress: clipboardAddress,
onOpenDnd: onOpenDnd,
onOpenBattery: onOpenBattery,
onOpenNotificationSettings: openAppSettings,
@@ -249,13 +344,18 @@ class _RecordingHud extends StatelessWidget {
Expanded(
child: Center(
child: GestureDetector(
onTap: state.isRecording ? onStop : onStart,
onTap: state.isStartingRecording
? null
: (state.isRecording ? onStop : onStart),
child: Container(
width: 76.w,
height: 76.h,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 4.r),
border: Border.all(
color: Colors.white,
width: 4.r,
),
color: state.isRecording
? Colors.white
: Colors.red,
@@ -280,17 +380,6 @@ class _RecordingHud extends StatelessWidget {
],
),
),
if (state.lastSavedDisplayName != null &&
!state.isRecording &&
!state.gallerySaveFailed)
Padding(
padding: EdgeInsets.only(bottom: 16.r),
child: Text(
'已保存到相册:${state.lastSavedDisplayName}',
style: TextStyle(color: Colors.white70, fontSize: 12.sp),
textAlign: TextAlign.center,
),
),
],
),
if (showPasteEventInfo)
@@ -324,9 +413,7 @@ class _RecordingHud extends StatelessWidget {
left: 12.w,
right: 12.w,
child: Padding(
padding: EdgeInsets.only(
right: state.isRecording ? 96.w : 0,
),
padding: EdgeInsets.only(right: state.isRecording ? 96.w : 0),
child: Text(
eventTitle!,
style: _overlayTextStyle.copyWith(
@@ -344,10 +431,7 @@ class _RecordingHud extends StatelessWidget {
top: 8.r,
right: 12.w,
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 12.r,
vertical: 6.r,
),
padding: EdgeInsets.symmetric(horizontal: 12.r, vertical: 6.r),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(20.r),
@@ -361,21 +445,21 @@ class _RecordingHud extends StatelessWidget {
),
),
),
if (eventAddress != null && eventAddress!.isNotEmpty)
Positioned(
left: 16.w,
bottom: 108.r,
right: 120.w,
child: Text(
eventAddress!,
style: _overlayTextStyle.copyWith(
fontSize: 13.sp,
color: Colors.white70,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
// if (eventAddress != null && eventAddress!.isNotEmpty)
// Positioned(
// left: 16.w,
// bottom: 108.r,
// right: 120.w,
// child: Text(
// eventAddress!,
// style: _overlayTextStyle.copyWith(
// fontSize: 13.sp,
// color: Colors.white70,
// ),
// maxLines: 2,
// overflow: TextOverflow.ellipsis,
// ),
// ),
],
),
);
@@ -387,6 +471,8 @@ class _SetupHints extends StatelessWidget {
required this.hasDndAccess,
required this.isBatteryIgnored,
required this.notificationsGranted,
this.showClipboardHint = false,
this.clipboardAddress = '',
required this.onOpenDnd,
required this.onOpenBattery,
required this.onOpenNotificationSettings,
@@ -395,13 +481,18 @@ class _SetupHints extends StatelessWidget {
final bool hasDndAccess;
final bool isBatteryIgnored;
final bool notificationsGranted;
final bool showClipboardHint;
final String clipboardAddress;
final VoidCallback onOpenDnd;
final VoidCallback onOpenBattery;
final VoidCallback onOpenNotificationSettings;
@override
Widget build(BuildContext context) {
if (hasDndAccess && isBatteryIgnored && notificationsGranted) {
final showPermissionHints =
!hasDndAccess || !isBatteryIgnored || !notificationsGranted;
final showClipboardHint = this.showClipboardHint;
if (!showPermissionHints && !showClipboardHint) {
return const SizedBox.shrink();
}
@@ -422,12 +513,59 @@ class _SetupHints extends StatelessWidget {
SizedBox(height: 8.h),
_HintChip(label: '关闭电池优化可提升息屏续录稳定性', onTap: onOpenBattery),
],
if (showClipboardHint) ...[
SizedBox(height: 8.h),
_ClipboardAddressClockChip(address: clipboardAddress),
],
],
),
);
}
}
class _ClipboardAddressClockChip extends StatefulWidget {
const _ClipboardAddressClockChip({required this.address});
final String address;
@override
State<_ClipboardAddressClockChip> createState() =>
_ClipboardAddressClockChipState();
}
class _ClipboardAddressClockChipState extends State<_ClipboardAddressClockChip> {
Timer? _clockTimer;
@override
void initState() {
super.initState();
_clockTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted) setState(() {});
});
}
@override
void dispose() {
_clockTimer?.cancel();
_clockTimer = null;
super.dispose();
}
String _buildLabel() {
final nowText = DateTimeFormatter.format(
DateTime.now(),
pattern: 'yyyy-M-d-H:mm:ss',
);
if (widget.address.isEmpty) return nowText;
return '${widget.address}\n$nowText';
}
@override
Widget build(BuildContext context) {
return _HintChip(label: _buildLabel(), onTap: () {});
}
}
class _HintChip extends StatelessWidget {
const _HintChip({required this.label, required this.onTap});

View File

@@ -14,6 +14,7 @@ class RecordingSessionState {
this.status = const RecordingStatus(state: RecordingState.idle),
this.isTouchLocked = true,
this.isPreviewReady = false,
this.isStartingRecording = false,
this.hasDndAccess = false,
this.isBatteryOptimizedIgnored = true,
this.notificationsGranted = true,
@@ -28,6 +29,7 @@ class RecordingSessionState {
final RecordingStatus status;
final bool isTouchLocked;
final bool isPreviewReady;
final bool isStartingRecording;
final bool hasDndAccess;
final bool isBatteryOptimizedIgnored;
final bool notificationsGranted;
@@ -51,6 +53,7 @@ class RecordingSessionState {
RecordingStatus? status,
bool? isTouchLocked,
bool? isPreviewReady,
bool? isStartingRecording,
bool? hasDndAccess,
bool? isBatteryOptimizedIgnored,
bool? notificationsGranted,
@@ -67,6 +70,7 @@ class RecordingSessionState {
status: status ?? this.status,
isTouchLocked: isTouchLocked ?? this.isTouchLocked,
isPreviewReady: isPreviewReady ?? this.isPreviewReady,
isStartingRecording: isStartingRecording ?? this.isStartingRecording,
hasDndAccess: hasDndAccess ?? this.hasDndAccess,
isBatteryOptimizedIgnored:
isBatteryOptimizedIgnored ?? this.isBatteryOptimizedIgnored,
@@ -204,11 +208,16 @@ class RecordingSessionController extends Notifier<RecordingSessionState> {
}
Future<void> startRecording({bool enableDoNotDisturb = true}) async {
if (!state.isPreviewReady || state.isRecording) return;
if (!state.isPreviewReady ||
state.isRecording ||
state.isStartingRecording) {
return;
}
final clipboard = ref.read(recordingViewModelProvider).clipboardRecordingModel;
final displayName = recordingFileNameForPlatform(clipboard.filename);
state = state.copyWith(isStartingRecording: true, errorMessage: null);
try {
final result = await RecordingPlatform.startRecording(
enableDoNotDisturb: enableDoNotDisturb && state.hasDndAccess,
@@ -224,6 +233,8 @@ class RecordingSessionController extends Notifier<RecordingSessionState> {
);
} on PlatformException catch (error) {
state = state.copyWith(errorMessage: error.message ?? '开始录制失败');
} finally {
state = state.copyWith(isStartingRecording: false);
}
}
@@ -254,6 +265,10 @@ class RecordingSessionController extends Notifier<RecordingSessionState> {
state = state.copyWith(isTouchLocked: locked);
}
void clearSavedRecordingResult() {
state = state.copyWith(clearLastSaved: true);
}
Future<void> openDndSettings() =>
RecordingPlatform.openNotificationPolicySettings();

View File

@@ -89,6 +89,10 @@ class RecordingViewModel extends StateNotifier<RecordingModel> {
}
}
void resetClipboardInfo() {
_resetClipboardInfo();
}
void _resetClipboardInfo() {
state = state.copyWith(
clipboardRecordingModel: _defaultClipboard,

View File

@@ -0,0 +1,120 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
/// 录制结束并保存到相册后的后续操作弹窗。
Future<void> showRecordingSavedDialog(
BuildContext context, {
required String sessionTitle,
required VoidCallback onContinueRound,
required VoidCallback onRecordNewRound,
}) {
return showDialog<void>(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
return _RecordingSavedDialog(
sessionTitle: sessionTitle,
onContinueRound: () {
Navigator.of(dialogContext).pop();
onContinueRound();
},
onRecordNewRound: () {
Navigator.of(dialogContext).pop();
onRecordNewRound();
},
);
},
);
}
class _RecordingSavedDialog extends StatelessWidget {
const _RecordingSavedDialog({
required this.sessionTitle,
required this.onContinueRound,
required this.onRecordNewRound,
});
final String sessionTitle;
final VoidCallback onContinueRound;
final VoidCallback onRecordNewRound;
@override
Widget build(BuildContext context) {
return Dialog(
backgroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4.r),
side: const BorderSide(color: Colors.black, width: 1),
),
insetPadding: EdgeInsets.symmetric(horizontal: 32.w),
child: Padding(
padding: EdgeInsets.fromLTRB(20.w, 20.h, 20.w, 16.h),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
sessionTitle,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
textAlign: TextAlign.center,
),
SizedBox(height: 16.h),
Text(
'本轮比赛视频已保存到相册',
style: TextStyle(fontSize: 14.sp, color: Colors.black87),
textAlign: TextAlign.center,
),
SizedBox(height: 8.h),
// Text(
// '请选择后续录制信息',
// style: TextStyle(fontSize: 14.sp, color: Colors.black87),
// textAlign: TextAlign.center,
// ),
SizedBox(height: 20.h),
Row(
children: [
Expanded(
child: _DialogActionButton(
label: '继续本轮',
onPressed: onContinueRound,
),
),
SizedBox(width: 12.w),
Expanded(
child: _DialogActionButton(
label: '录制新轮',
onPressed: onRecordNewRound,
),
),
],
),
],
),
),
);
}
}
class _DialogActionButton extends StatelessWidget {
const _DialogActionButton({required this.label, required this.onPressed});
final String label;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return TextButton(
onPressed: onPressed,
style: TextButton.styleFrom(
backgroundColor: const Color(0xFFE8E8E8),
foregroundColor: Colors.black87,
padding: EdgeInsets.symmetric(vertical: 10.h),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6.r)),
),
child: Text(label, style: TextStyle(fontSize: 14.sp)),
);
}
}