From 77d9c355929cccbd5e98d7b90a8bd310137f7fc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E9=94=8B?= <2535831261@qq.com> Date: Thu, 4 Jun 2026 16:25:26 +0800 Subject: [PATCH] =?UTF-8?q?1.=E7=A1=AE=E5=AE=9A=20APP=20=E5=8C=85=E5=90=8D?= =?UTF-8?q?=202.=E5=BD=95=E5=88=B6=E7=BB=93=E6=9D=9F=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E5=BC=B9=E7=AA=97=E6=8F=90=E7=A4=BA=203.=E5=AE=8C=E6=88=90?= =?UTF-8?q?=E8=AF=BB=E5=8F=96=E5=89=AA=E5=88=87=E6=9D=BF=E5=86=85=E5=AE=B9?= =?UTF-8?q?=E3=80=81=E6=96=B0=E5=A2=9E=E7=B2=98=E8=B4=B4=E5=89=AA=E5=88=87?= =?UTF-8?q?=E6=9D=BF=E5=86=85=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/build.gradle.kts | 2 +- android/app/src/main/AndroidManifest.xml | 18 +- .../main/kotlin/com/gdfw/fxjk/AppConstants.kt | 4 +- .../main/kotlin/com/gdfw/fxjk/MainActivity.kt | 115 +++++----- .../recording/BatteryOptimizationHelper.kt | 16 +- .../gdfw/fxjk/recording/DoNotDisturbHelper.kt | 10 +- .../recording/RecordingCameraController.kt | 197 +++++++++--------- .../recording/RecordingForegroundService.kt | 96 +++++---- .../fxjk/recording/RecordingOutputFactory.kt | 31 ++- .../recording/RecordingPlatformHandler.kt | 103 +++++---- .../fxjk/recording/RecordingPreviewFactory.kt | 16 +- .../gdfw/fxjk/recording/RecordingSession.kt | 8 +- .../com/gdfw/fxjk/recording/RecordingState.kt | 22 +- ios/Runner/PlatformInfoPlugin.swift | 2 +- ios/Runner/RecordingPlugin.swift | 34 ++- lib/app/bootstrap.dart | 25 ++- lib/core/platform/app_platform_info.dart | 2 +- .../recording/model/model_clipboard.dart | 8 +- .../recording/recording_channel_names.dart | 2 +- lib/features/recording/recording_page.dart | 183 ++++++++++++---- .../recording_session_controller.dart | 17 +- .../view-model/view_model_recording.dart | 4 + .../widgets/recording_saved_dialog.dart | 120 +++++++++++ 23 files changed, 652 insertions(+), 383 deletions(-) create mode 100644 lib/features/recording/widgets/recording_saved_dialog.dart diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 0162c54..8641ba9 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -4,7 +4,7 @@ plugins { id("dev.flutter.flutter-gradle-plugin") } -val appPackageName = "com.gdfw.fxjk" +val appPackageName = "com.qxy.dronex" android { namespace = appPackageName diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 6abbcf5..267c67d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,5 @@ + package="com.qxy.dronex"> @@ -32,12 +32,12 @@ android:hardwareAccelerated="true" android:windowSoftInputMode="adjustResize"> + android:name="io.flutter.embedding.android.NormalTheme" + android:resource="@style/NormalTheme" + /> - - + + @@ -52,8 +52,8 @@ - - + + - + \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/gdfw/fxjk/AppConstants.kt b/android/app/src/main/kotlin/com/gdfw/fxjk/AppConstants.kt index 4e17c50..a87f01a 100644 --- a/android/app/src/main/kotlin/com/gdfw/fxjk/AppConstants.kt +++ b/android/app/src/main/kotlin/com/gdfw/fxjk/AppConstants.kt @@ -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" diff --git a/android/app/src/main/kotlin/com/gdfw/fxjk/MainActivity.kt b/android/app/src/main/kotlin/com/gdfw/fxjk/MainActivity.kt index 302579d..f9f3e90 100644 --- a/android/app/src/main/kotlin/com/gdfw/fxjk/MainActivity.kt +++ b/android/app/src/main/kotlin/com/gdfw/fxjk/MainActivity.kt @@ -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 { 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 { 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, ) } } diff --git a/android/app/src/main/kotlin/com/gdfw/fxjk/recording/BatteryOptimizationHelper.kt b/android/app/src/main/kotlin/com/gdfw/fxjk/recording/BatteryOptimizationHelper.kt index 7f65f28..dc5a50b 100644 --- a/android/app/src/main/kotlin/com/gdfw/fxjk/recording/BatteryOptimizationHelper.kt +++ b/android/app/src/main/kotlin/com/gdfw/fxjk/recording/BatteryOptimizationHelper.kt @@ -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) } } diff --git a/android/app/src/main/kotlin/com/gdfw/fxjk/recording/DoNotDisturbHelper.kt b/android/app/src/main/kotlin/com/gdfw/fxjk/recording/DoNotDisturbHelper.kt index ec00e90..7949d8c 100644 --- a/android/app/src/main/kotlin/com/gdfw/fxjk/recording/DoNotDisturbHelper.kt +++ b/android/app/src/main/kotlin/com/gdfw/fxjk/recording/DoNotDisturbHelper.kt @@ -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) } diff --git a/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingCameraController.kt b/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingCameraController.kt index c4ba0c3..a225439 100644 --- a/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingCameraController.kt +++ b/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingCameraController.kt @@ -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() diff --git a/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingForegroundService.kt b/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingForegroundService.kt index 6de5d12..6f4543a 100644 --- a/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingForegroundService.kt +++ b/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingForegroundService.kt @@ -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) } } diff --git a/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingOutputFactory.kt b/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingOutputFactory.kt index 0d04bab..e453b3a 100644 --- a/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingOutputFactory.kt +++ b/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingOutputFactory.kt @@ -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" } } diff --git a/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingPlatformHandler.kt b/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingPlatformHandler.kt index e9ee8c7..8ce8657 100644 --- a/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingPlatformHandler.kt +++ b/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingPlatformHandler.kt @@ -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( - "outputPath" to path, - "status" to controller.status.toMap(), - "gallerySaved" to gallerySaved, - ) + val gallerySaved = path != null && controller.status.state != RecordingState.ERROR + val payload = + mutableMapOf( + "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() { diff --git a/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingPreviewFactory.kt b/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingPreviewFactory.kt index efbc8fa..21e00c2 100644 --- a/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingPreviewFactory.kt +++ b/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingPreviewFactory.kt @@ -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) diff --git a/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingSession.kt b/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingSession.kt index ccd3189..4eba227 100644 --- a/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingSession.kt +++ b/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingSession.kt @@ -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() { diff --git a/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingState.kt b/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingState.kt index fa6aef4..6cf5043 100644 --- a/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingState.kt +++ b/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingState.kt @@ -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 = - 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, + ) } diff --git a/ios/Runner/PlatformInfoPlugin.swift b/ios/Runner/PlatformInfoPlugin.swift index ee64159..87329f4 100644 --- a/ios/Runner/PlatformInfoPlugin.swift +++ b/ios/Runner/PlatformInfoPlugin.swift @@ -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() diff --git a/ios/Runner/RecordingPlugin.swift b/ios/Runner/RecordingPlugin.swift index 3f0ee1b..388e7f4 100644 --- a/ios/Runner/RecordingPlugin.swift +++ b/ios/Runner/RecordingPlugin.swift @@ -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 diff --git a/lib/app/bootstrap.dart b/lib/app/bootstrap.dart index 84b6589..b412b10 100644 --- a/lib/app/bootstrap.dart +++ b/lib/app/bootstrap.dart @@ -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 _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, + ); + } } } diff --git a/lib/core/platform/app_platform_info.dart b/lib/core/platform/app_platform_info.dart index 3aba846..7e3bcde 100644 --- a/lib/core/platform/app_platform_info.dart +++ b/lib/core/platform/app_platform_info.dart @@ -59,7 +59,7 @@ class AppPlatformInfo { AppPlatformInfo._(); static const MethodChannel _channel = MethodChannel( - 'com.gdfw.fxjk/platform_info', + 'com.qxy.dronex/platform_info', ); static Future packageInfo() async { diff --git a/lib/features/recording/model/model_clipboard.dart b/lib/features/recording/model/model_clipboard.dart index aa5db7b..915c349 100644 --- a/lib/features/recording/model/model_clipboard.dart +++ b/lib/features/recording/model/model_clipboard.dart @@ -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, }); diff --git a/lib/features/recording/recording_channel_names.dart b/lib/features/recording/recording_channel_names.dart index ae2f4e0..faa0112 100644 --- a/lib/features/recording/recording_channel_names.dart +++ b/lib/features/recording/recording_channel_names.dart @@ -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'; } diff --git a/lib/features/recording/recording_page.dart b/lib/features/recording/recording_page.dart index 1f449ed..2c949a1 100644 --- a/lib/features/recording/recording_page.dart +++ b/lib/features/recording/recording_page.dart @@ -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 { _immersiveApplied = true; } + String _clipboardHintLabel(RecordingModel recordingInfo) { + if (!recordingInfo.hasValidClipboardInfo) return ''; + final clip = recordingInfo.clipboardRecordingModel; + final lines = []; + 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 _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 _exitRecordingMode() async { if (!_immersiveApplied) return; await ref.read(recordingSessionControllerProvider.notifier).teardown(); @@ -100,6 +167,8 @@ class _RecordingPageState extends ConsumerState { 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 { 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 { 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 { 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 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: () {}), + ], ], ), ); diff --git a/lib/features/recording/recording_session_controller.dart b/lib/features/recording/recording_session_controller.dart index 87a53fb..0f30b31 100644 --- a/lib/features/recording/recording_session_controller.dart +++ b/lib/features/recording/recording_session_controller.dart @@ -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 { } Future 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 { ); } on PlatformException catch (error) { state = state.copyWith(errorMessage: error.message ?? '开始录制失败'); + } finally { + state = state.copyWith(isStartingRecording: false); } } @@ -254,6 +265,10 @@ class RecordingSessionController extends Notifier { state = state.copyWith(isTouchLocked: locked); } + void clearSavedRecordingResult() { + state = state.copyWith(clearLastSaved: true); + } + Future openDndSettings() => RecordingPlatform.openNotificationPolicySettings(); diff --git a/lib/features/recording/view-model/view_model_recording.dart b/lib/features/recording/view-model/view_model_recording.dart index 1b2ec4a..66debce 100644 --- a/lib/features/recording/view-model/view_model_recording.dart +++ b/lib/features/recording/view-model/view_model_recording.dart @@ -89,6 +89,10 @@ class RecordingViewModel extends StateNotifier { } } + void resetClipboardInfo() { + _resetClipboardInfo(); + } + void _resetClipboardInfo() { state = state.copyWith( clipboardRecordingModel: _defaultClipboard, diff --git a/lib/features/recording/widgets/recording_saved_dialog.dart b/lib/features/recording/widgets/recording_saved_dialog.dart new file mode 100644 index 0000000..19792ce --- /dev/null +++ b/lib/features/recording/widgets/recording_saved_dialog.dart @@ -0,0 +1,120 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +/// 录制结束并保存到相册后的后续操作弹窗。 +Future showRecordingSavedDialog( + BuildContext context, { + required String sessionTitle, + required VoidCallback onContinueRound, + required VoidCallback onRecordNewRound, +}) { + return showDialog( + 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)), + ); + } +}