1.确定 APP 包名

2.录制结束增加弹窗提示
3.完成读取剪切板内容、新增粘贴剪切板内容
This commit is contained in:
2026-06-04 16:25:26 +08:00
parent 5ddcb95358
commit 77d9c35592
23 changed files with 652 additions and 383 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" />
@@ -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>

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

@@ -1,10 +1,10 @@
package com.gdfw.fxjk
package com.qxy.dronex
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 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
@@ -18,33 +18,31 @@ class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
flutterEngine
.platformViewsController
.registry
.registerViewFactory(
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()
}
}
}
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,
)
RecordingPlatformHandler(
this,
flutterEngine.dartExecutor.binaryMessenger,
)
}
fun attachRecordingPreview(previewView: PreviewView) {
@@ -67,54 +65,51 @@ class MainActivity : FlutterActivity() {
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)
}
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 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()
}
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,
"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")
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,
"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

@@ -1,4 +1,4 @@
package com.gdfw.fxjk.recording
package com.qxy.dronex.recording
import android.content.Context
import android.util.Log
@@ -17,7 +17,7 @@ import androidx.lifecycle.LifecycleOwner
import java.util.concurrent.Executor
class RecordingCameraController(
private val appContext: Context,
private val appContext: Context,
) {
private val mainExecutor: Executor = ContextCompat.getMainExecutor(appContext)
@@ -37,58 +37,58 @@ class RecordingCameraController(
private var pendingStopCallback: ((String?) -> Unit)? = null
fun bindPreview(
lifecycleOwner: LifecycleOwner,
previewView: PreviewView,
onReady: (Boolean) -> Unit,
lifecycleOwner: LifecycleOwner,
previewView: PreviewView,
onReady: (Boolean) -> Unit,
) {
val future = ProcessCameraProvider.getInstance(appContext)
future.addListener(
{
try {
val provider = future.get()
cameraProvider = provider
boundLifecycleOwner = lifecycleOwner
{
try {
val provider = future.get()
cameraProvider = provider
boundLifecycleOwner = lifecycleOwner
preview =
Preview.Builder().build().also {
it.surfaceProvider = previewView.surfaceProvider
}
preview =
Preview.Builder().build().also {
it.surfaceProvider = previewView.surfaceProvider
}
val recorder =
Recorder.Builder()
.setQualitySelector(QualitySelector.from(Quality.HD))
.build()
videoCapture = VideoCapture.withOutput(recorder)
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,
)
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,
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,
lifecycleOwner: LifecycleOwner,
previewView: PreviewView,
onReady: (Boolean) -> Unit,
) {
val provider = cameraProvider
if (provider == null) {
@@ -100,10 +100,10 @@ class RecordingCameraController(
boundLifecycleOwner = lifecycleOwner
provider.unbindAll()
provider.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
preview,
videoCapture,
lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
preview,
videoCapture,
)
onReady(true)
} catch (error: Exception) {
@@ -113,9 +113,9 @@ class RecordingCameraController(
}
fun startRecording(
withAudio: Boolean,
displayName: String?,
onStarted: (Boolean, String?) -> Unit,
withAudio: Boolean,
displayName: String?,
onStarted: (Boolean, String?) -> Unit,
) {
val capture = videoCapture
if (capture == null || boundLifecycleOwner == null) {
@@ -129,63 +129,66 @@ class RecordingCameraController(
}
val outputOptions =
RecordingOutputFactory.buildMediaStoreOutputOptions(
appContext,
displayName,
)
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()
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,
),
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,
),
)
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)
}
val stopCallback = pendingStopCallback
pendingStopCallback = null
stopCallback?.invoke(latestOutputPath)
}
}
}
onStarted(true, latestOutputPath ?: "recording")
}
@@ -199,10 +202,10 @@ class RecordingCameraController(
pendingStopCallback = onStopped
updateStatus(
RecordingStatus(
RecordingState.STOPPING,
outputPath = latestOutputPath,
),
RecordingStatus(
RecordingState.STOPPING,
outputPath = latestOutputPath,
),
)
recording.stop()

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
@@ -14,25 +14,25 @@ object RecordingOutputFactory {
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

@@ -1,4 +1,4 @@
package com.gdfw.fxjk.recording
package com.qxy.dronex.recording
enum class RecordingState {
IDLE,
@@ -9,16 +9,16 @@ enum class RecordingState {
}
data class RecordingStatus(
val state: RecordingState,
val outputPath: String? = null,
val elapsedMillis: Long = 0L,
val message: String? = null,
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,
)
mapOf(
"state" to state.name.lowercase(),
"outputPath" to outputPath,
"elapsedMillis" to elapsedMillis,
"message" to message,
)
}

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

@@ -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

@@ -2,13 +2,17 @@ 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 +56,69 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
_immersiveApplied = true;
}
String _clipboardHintLabel(RecordingModel recordingInfo) {
if (!recordingInfo.hasValidClipboardInfo) return '';
final clip = recordingInfo.clipboardRecordingModel;
final lines = <String>[];
final address = clip.address.trim();
if (address.isNotEmpty) {
lines.add(address);
}
if (clip.startTimestamp > 0) {
final startTime = DateTime.fromMillisecondsSinceEpoch(
clip.startTimestamp * 1000,
).toLocal();
lines.add(
DateTimeFormatter.format(startTime, pattern: 'yyyy-M-d-H:mm:ss'),
);
}
return lines.join('\n');
}
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 +167,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 +178,7 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
state: state,
eventTitle: showClipboardInfo ? clipboard.title : null,
eventAddress: showClipboardInfo ? clipboard.address : null,
clipboardHintLabel: _clipboardHintLabel(recordingInfo),
onPasteEventInfo: () async {
final result = await ref
.read(recordingViewModelProvider.notifier)
@@ -125,7 +195,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 +211,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 +257,7 @@ class _RecordingHud extends StatelessWidget {
required this.state,
this.eventTitle,
this.eventAddress,
this.clipboardHintLabel,
required this.onPasteEventInfo,
required this.onStart,
required this.onStop,
@@ -162,6 +269,7 @@ class _RecordingHud extends StatelessWidget {
final RecordingSessionState state;
final String? eventTitle;
final String? eventAddress;
final String? clipboardHintLabel;
final Future<void> Function() onPasteEventInfo;
final VoidCallback onStart;
final VoidCallback onStop;
@@ -222,6 +330,7 @@ class _RecordingHud extends StatelessWidget {
hasDndAccess: state.hasDndAccess,
isBatteryIgnored: state.isBatteryOptimizedIgnored,
notificationsGranted: state.notificationsGranted,
clipboardHintLabel: clipboardHintLabel,
onOpenDnd: onOpenDnd,
onOpenBattery: onOpenBattery,
onOpenNotificationSettings: openAppSettings,
@@ -249,13 +358,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 +394,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 +427,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 +445,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 +459,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 +485,7 @@ class _SetupHints extends StatelessWidget {
required this.hasDndAccess,
required this.isBatteryIgnored,
required this.notificationsGranted,
this.clipboardHintLabel,
required this.onOpenDnd,
required this.onOpenBattery,
required this.onOpenNotificationSettings,
@@ -395,13 +494,18 @@ class _SetupHints extends StatelessWidget {
final bool hasDndAccess;
final bool isBatteryIgnored;
final bool notificationsGranted;
final String? clipboardHintLabel;
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 =
clipboardHintLabel != null && clipboardHintLabel!.isNotEmpty;
if (!showPermissionHints && !showClipboardHint) {
return const SizedBox.shrink();
}
@@ -422,6 +526,11 @@ class _SetupHints extends StatelessWidget {
SizedBox(height: 8.h),
_HintChip(label: '关闭电池优化可提升息屏续录稳定性', onTap: onOpenBattery),
],
if (clipboardHintLabel != null &&
clipboardHintLabel!.isNotEmpty) ...[
SizedBox(height: 8.h),
_HintChip(label: clipboardHintLabel!, 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)),
);
}
}