1.确定 APP 包名
2.录制结束增加弹窗提示 3.完成读取剪切板内容、新增粘贴剪切板内容
This commit is contained in:
@@ -4,7 +4,7 @@ plugins {
|
|||||||
id("dev.flutter.flutter-gradle-plugin")
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
}
|
}
|
||||||
|
|
||||||
val appPackageName = "com.gdfw.fxjk"
|
val appPackageName = "com.qxy.dronex"
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = appPackageName
|
namespace = appPackageName
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
package="com.gdfw.fxjk">
|
package="com.qxy.dronex">
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
@@ -36,8 +36,8 @@
|
|||||||
android:resource="@style/NormalTheme"
|
android:resource="@style/NormalTheme"
|
||||||
/>
|
/>
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
@@ -52,8 +52,8 @@
|
|||||||
</application>
|
</application>
|
||||||
<queries>
|
<queries>
|
||||||
<intent>
|
<intent>
|
||||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
<action android:name="android.intent.action.PROCESS_TEXT" />
|
||||||
<data android:mimeType="text/plain"/>
|
<data android:mimeType="text/plain" />
|
||||||
</intent>
|
</intent>
|
||||||
</queries>
|
</queries>
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.gdfw.fxjk
|
package com.qxy.dronex
|
||||||
|
|
||||||
object AppConstants {
|
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 PLATFORM_INFO_CHANNEL = "$PACKAGE_NAME/platform_info"
|
||||||
const val RECORDING_METHOD_CHANNEL = "$PACKAGE_NAME/recording"
|
const val RECORDING_METHOD_CHANNEL = "$PACKAGE_NAME/recording"
|
||||||
const val RECORDING_EVENT_CHANNEL = "$PACKAGE_NAME/recording_events"
|
const val RECORDING_EVENT_CHANNEL = "$PACKAGE_NAME/recording_events"
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
package com.gdfw.fxjk
|
package com.qxy.dronex
|
||||||
|
|
||||||
import android.content.pm.ApplicationInfo
|
import android.content.pm.ApplicationInfo
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.camera.view.PreviewView
|
import androidx.camera.view.PreviewView
|
||||||
import com.gdfw.fxjk.recording.RecordingPlatformHandler
|
import com.qxy.dronex.recording.RecordingPlatformHandler
|
||||||
import com.gdfw.fxjk.recording.RecordingPreviewFactory
|
import com.qxy.dronex.recording.RecordingPreviewFactory
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
@@ -18,10 +18,7 @@ class MainActivity : FlutterActivity() {
|
|||||||
|
|
||||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
flutterEngine
|
flutterEngine.platformViewsController.registry.registerViewFactory(
|
||||||
.platformViewsController
|
|
||||||
.registry
|
|
||||||
.registerViewFactory(
|
|
||||||
"recording-camera-preview",
|
"recording-camera-preview",
|
||||||
RecordingPreviewFactory(this),
|
RecordingPreviewFactory(this),
|
||||||
)
|
)
|
||||||
@@ -30,7 +27,8 @@ class MainActivity : FlutterActivity() {
|
|||||||
MethodChannel(
|
MethodChannel(
|
||||||
flutterEngine.dartExecutor.binaryMessenger,
|
flutterEngine.dartExecutor.binaryMessenger,
|
||||||
AppConstants.PLATFORM_INFO_CHANNEL,
|
AppConstants.PLATFORM_INFO_CHANNEL,
|
||||||
).also { channel ->
|
)
|
||||||
|
.also { channel ->
|
||||||
channel.setMethodCallHandler { call, result ->
|
channel.setMethodCallHandler { call, result ->
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
"packageInfo" -> result.success(packageInfoMap())
|
"packageInfo" -> result.success(packageInfoMap())
|
||||||
@@ -73,18 +71,15 @@ class MainActivity : FlutterActivity() {
|
|||||||
android.content.pm.PackageManager.PackageInfoFlags.of(0),
|
android.content.pm.PackageManager.PackageInfoFlags.of(0),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION") packageManager.getPackageInfo(packageName, 0)
|
||||||
packageManager.getPackageInfo(packageName, 0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val appName =
|
val appName = applicationInfo.loadLabel(packageManager)?.toString().orEmpty()
|
||||||
applicationInfo.loadLabel(packageManager)?.toString().orEmpty()
|
|
||||||
val versionCode =
|
val versionCode =
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
packageInfo.longVersionCode.toString()
|
packageInfo.longVersionCode.toString()
|
||||||
} else {
|
} else {
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION") packageInfo.versionCode.toString()
|
||||||
packageInfo.versionCode.toString()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return mapOf(
|
return mapOf(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.gdfw.fxjk.recording
|
package com.qxy.dronex.recording
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
package com.gdfw.fxjk.recording
|
package com.qxy.dronex.recording
|
||||||
|
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
|
||||||
@@ -16,7 +15,8 @@ object DoNotDisturbHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun openAccessSettings(context: Context) {
|
fun openAccessSettings(context: Context) {
|
||||||
val intent = Intent(Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS).apply {
|
val intent =
|
||||||
|
Intent(Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS).apply {
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
}
|
}
|
||||||
context.startActivity(intent)
|
context.startActivity(intent)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.gdfw.fxjk.recording
|
package com.qxy.dronex.recording
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
@@ -167,7 +167,8 @@ class RecordingCameraController(
|
|||||||
updateStatus(
|
updateStatus(
|
||||||
RecordingStatus(
|
RecordingStatus(
|
||||||
RecordingState.ERROR,
|
RecordingState.ERROR,
|
||||||
message = event.cause?.message ?: "Recording failed",
|
message = event.cause?.message
|
||||||
|
?: "Recording failed",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -176,7 +177,9 @@ class RecordingCameraController(
|
|||||||
RecordingStatus(
|
RecordingStatus(
|
||||||
RecordingState.PREVIEWING,
|
RecordingState.PREVIEWING,
|
||||||
outputPath = latestOutputPath,
|
outputPath = latestOutputPath,
|
||||||
elapsedMillis = System.currentTimeMillis() - recordingStartedAt,
|
elapsedMillis =
|
||||||
|
System.currentTimeMillis() -
|
||||||
|
recordingStartedAt,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,21 @@
|
|||||||
package com.gdfw.fxjk.recording
|
package com.qxy.dronex.recording
|
||||||
|
|
||||||
import android.app.Notification
|
import android.app.Notification
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.app.Service
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.content.pm.ServiceInfo
|
import android.content.pm.ServiceInfo
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.os.PowerManager
|
import android.os.PowerManager
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.lifecycle.LifecycleService
|
import androidx.lifecycle.LifecycleService
|
||||||
import com.gdfw.fxjk.AppConstants
|
import com.qxy.dronex.AppConstants
|
||||||
import com.gdfw.fxjk.MainActivity
|
import com.qxy.dronex.MainActivity
|
||||||
|
|
||||||
class RecordingForegroundService : LifecycleService() {
|
class RecordingForegroundService : LifecycleService() {
|
||||||
private var wakeLock: PowerManager.WakeLock? = null
|
private var wakeLock: PowerManager.WakeLock? = null
|
||||||
@@ -94,7 +93,8 @@ class RecordingForegroundService : LifecycleService() {
|
|||||||
CHANNEL_ID,
|
CHANNEL_ID,
|
||||||
"录制服务",
|
"录制服务",
|
||||||
NotificationManager.IMPORTANCE_LOW,
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
).apply {
|
)
|
||||||
|
.apply {
|
||||||
description = "保持相机录制在后台与息屏时继续运行"
|
description = "保持相机录制在后台与息屏时继续运行"
|
||||||
setShowBadge(false)
|
setShowBadge(false)
|
||||||
}
|
}
|
||||||
@@ -146,11 +146,9 @@ class RecordingForegroundService : LifecycleService() {
|
|||||||
const val NOTIFICATION_ID = 1001
|
const val NOTIFICATION_ID = 1001
|
||||||
private const val WAKE_LOCK_TAG = "record_tool:recording_wake_lock"
|
private const val WAKE_LOCK_TAG = "record_tool:recording_wake_lock"
|
||||||
|
|
||||||
@Volatile
|
@Volatile var isRunning: Boolean = false
|
||||||
var isRunning: Boolean = false
|
|
||||||
|
|
||||||
@Volatile
|
@Volatile var instance: RecordingForegroundService? = null
|
||||||
var instance: RecordingForegroundService? = null
|
|
||||||
|
|
||||||
fun start(context: Context) {
|
fun start(context: Context) {
|
||||||
val intent =
|
val intent =
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.gdfw.fxjk.recording
|
package com.qxy.dronex.recording
|
||||||
|
|
||||||
import android.content.ContentValues
|
import android.content.ContentValues
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -44,8 +44,7 @@ object RecordingOutputFactory {
|
|||||||
"$trimmed.mp4"
|
"$trimmed.mp4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val timestamp =
|
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
||||||
SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
|
||||||
return "REC_$timestamp.mp4"
|
return "REC_$timestamp.mp4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
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.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.core.view.WindowInsetsControllerCompat
|
import androidx.core.view.WindowInsetsControllerCompat
|
||||||
import com.gdfw.fxjk.AppConstants
|
import com.qxy.dronex.AppConstants
|
||||||
import com.gdfw.fxjk.MainActivity
|
import com.qxy.dronex.MainActivity
|
||||||
import io.flutter.plugin.common.BinaryMessenger
|
import io.flutter.plugin.common.BinaryMessenger
|
||||||
import io.flutter.plugin.common.EventChannel
|
import io.flutter.plugin.common.EventChannel
|
||||||
import io.flutter.plugin.common.MethodCall
|
import io.flutter.plugin.common.MethodCall
|
||||||
@@ -18,10 +16,8 @@ class RecordingPlatformHandler(
|
|||||||
private val activity: MainActivity,
|
private val activity: MainActivity,
|
||||||
messenger: BinaryMessenger,
|
messenger: BinaryMessenger,
|
||||||
) : MethodChannel.MethodCallHandler, EventChannel.StreamHandler {
|
) : MethodChannel.MethodCallHandler, EventChannel.StreamHandler {
|
||||||
private val methodChannel =
|
private val methodChannel = MethodChannel(messenger, AppConstants.RECORDING_METHOD_CHANNEL)
|
||||||
MethodChannel(messenger, AppConstants.RECORDING_METHOD_CHANNEL)
|
private val eventChannel = EventChannel(messenger, AppConstants.RECORDING_EVENT_CHANNEL)
|
||||||
private val eventChannel =
|
|
||||||
EventChannel(messenger, AppConstants.RECORDING_EVENT_CHANNEL)
|
|
||||||
|
|
||||||
private val mainHandler = Handler(Looper.getMainLooper())
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
private var eventSink: EventChannel.EventSink? = null
|
private var eventSink: EventChannel.EventSink? = null
|
||||||
@@ -33,9 +29,7 @@ class RecordingPlatformHandler(
|
|||||||
methodChannel.setMethodCallHandler(this)
|
methodChannel.setMethodCallHandler(this)
|
||||||
eventChannel.setStreamHandler(this)
|
eventChannel.setStreamHandler(this)
|
||||||
controller.statusListener = { status ->
|
controller.statusListener = { status ->
|
||||||
mainHandler.post {
|
mainHandler.post { eventSink?.success(status.toMap()) }
|
||||||
eventSink?.success(status.toMap())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,14 +54,12 @@ class RecordingPlatformHandler(
|
|||||||
controller.unbind()
|
controller.unbind()
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
"hasNotificationPolicyAccess" ->
|
"hasNotificationPolicyAccess" -> result.success(DoNotDisturbHelper.hasAccess(activity))
|
||||||
result.success(DoNotDisturbHelper.hasAccess(activity))
|
|
||||||
"openNotificationPolicySettings" -> {
|
"openNotificationPolicySettings" -> {
|
||||||
DoNotDisturbHelper.openAccessSettings(activity)
|
DoNotDisturbHelper.openAccessSettings(activity)
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
"enableDoNotDisturb" ->
|
"enableDoNotDisturb" -> result.success(DoNotDisturbHelper.enable(activity))
|
||||||
result.success(DoNotDisturbHelper.enable(activity))
|
|
||||||
"disableDoNotDisturb" -> {
|
"disableDoNotDisturb" -> {
|
||||||
DoNotDisturbHelper.disable(activity)
|
DoNotDisturbHelper.disable(activity)
|
||||||
result.success(null)
|
result.success(null)
|
||||||
@@ -84,8 +76,7 @@ class RecordingPlatformHandler(
|
|||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
"getStatus" -> result.success(controller.status.toMap())
|
"getStatus" -> result.success(controller.status.toMap())
|
||||||
"isForegroundServiceRunning" ->
|
"isForegroundServiceRunning" -> result.success(RecordingForegroundService.isRunning)
|
||||||
result.success(RecordingForegroundService.isRunning)
|
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,8 +138,7 @@ class RecordingPlatformHandler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun rebindAndCapture() {
|
fun rebindAndCapture() {
|
||||||
val lifecycleOwner =
|
val lifecycleOwner = RecordingForegroundService.instance ?: activity
|
||||||
RecordingForegroundService.instance ?: activity
|
|
||||||
controller.rebindForRecording(lifecycleOwner, previewView) { ready ->
|
controller.rebindForRecording(lifecycleOwner, previewView) { ready ->
|
||||||
if (ready) {
|
if (ready) {
|
||||||
beginCapture()
|
beginCapture()
|
||||||
@@ -172,17 +162,15 @@ class RecordingPlatformHandler(
|
|||||||
RecordingSession.stopForeground(activity)
|
RecordingSession.stopForeground(activity)
|
||||||
DoNotDisturbHelper.disable(activity)
|
DoNotDisturbHelper.disable(activity)
|
||||||
mainHandler.post {
|
mainHandler.post {
|
||||||
val gallerySaved =
|
val gallerySaved = path != null && controller.status.state != RecordingState.ERROR
|
||||||
path != null &&
|
val payload =
|
||||||
controller.status.state != RecordingState.ERROR
|
mutableMapOf<String, Any?>(
|
||||||
val payload = mutableMapOf<String, Any?>(
|
|
||||||
"outputPath" to path,
|
"outputPath" to path,
|
||||||
"status" to controller.status.toMap(),
|
"status" to controller.status.toMap(),
|
||||||
"gallerySaved" to gallerySaved,
|
"gallerySaved" to gallerySaved,
|
||||||
)
|
)
|
||||||
if (!gallerySaved) {
|
if (!gallerySaved) {
|
||||||
payload["galleryErrorMessage"] =
|
payload["galleryErrorMessage"] = controller.status.message ?: "保存到相册失败"
|
||||||
controller.status.message ?: "保存到相册失败"
|
|
||||||
}
|
}
|
||||||
result.success(payload)
|
result.success(payload)
|
||||||
}
|
}
|
||||||
@@ -209,16 +197,19 @@ class RecordingPlatformHandler(
|
|||||||
override fun run() {
|
override fun run() {
|
||||||
if (controller.status.state == RecordingState.RECORDING) {
|
if (controller.status.state == RecordingState.RECORDING) {
|
||||||
eventSink?.success(
|
eventSink?.success(
|
||||||
controller.status.copy(
|
controller
|
||||||
elapsedMillis = controller.elapsedMillis(),
|
.status
|
||||||
).toMap(),
|
.copy(
|
||||||
|
elapsedMillis =
|
||||||
|
controller.elapsedMillis(),
|
||||||
|
)
|
||||||
|
.toMap(),
|
||||||
)
|
)
|
||||||
mainHandler.postDelayed(this, 1000L)
|
mainHandler.postDelayed(this, 1000L)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.also {
|
|
||||||
mainHandler.post(it)
|
|
||||||
}
|
}
|
||||||
|
.also { mainHandler.post(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun stopElapsedTicker() {
|
private fun stopElapsedTicker() {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package com.gdfw.fxjk.recording
|
package com.qxy.dronex.recording
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.camera.view.PreviewView
|
import androidx.camera.view.PreviewView
|
||||||
import com.gdfw.fxjk.MainActivity
|
import com.qxy.dronex.MainActivity
|
||||||
import io.flutter.plugin.common.StandardMessageCodec
|
import io.flutter.plugin.common.StandardMessageCodec
|
||||||
import io.flutter.plugin.platform.PlatformView
|
import io.flutter.plugin.platform.PlatformView
|
||||||
import io.flutter.plugin.platform.PlatformViewFactory
|
import io.flutter.plugin.platform.PlatformViewFactory
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.gdfw.fxjk.recording
|
package com.qxy.dronex.recording
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.lifecycle.LifecycleService
|
import androidx.lifecycle.LifecycleService
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.gdfw.fxjk.recording
|
package com.qxy.dronex.recording
|
||||||
|
|
||||||
enum class RecordingState {
|
enum class RecordingState {
|
||||||
IDLE,
|
IDLE,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import UIKit
|
|||||||
final class PlatformInfoPlugin: NSObject, FlutterPlugin {
|
final class PlatformInfoPlugin: NSObject, FlutterPlugin {
|
||||||
static func register(with registrar: FlutterPluginRegistrar) {
|
static func register(with registrar: FlutterPluginRegistrar) {
|
||||||
let channel = FlutterMethodChannel(
|
let channel = FlutterMethodChannel(
|
||||||
name: "com.gdfw.fxjk/platform_info",
|
name: "com.qxy.dronex/platform_info",
|
||||||
binaryMessenger: registrar.messenger()
|
binaryMessenger: registrar.messenger()
|
||||||
)
|
)
|
||||||
let plugin = PlatformInfoPlugin()
|
let plugin = PlatformInfoPlugin()
|
||||||
|
|||||||
@@ -142,7 +142,8 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
|||||||
|
|
||||||
func initializePreview(result: @escaping FlutterResult) {
|
func initializePreview(result: @escaping FlutterResult) {
|
||||||
guard let previewView else {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,7 +177,8 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
|||||||
|
|
||||||
func startRecording(withAudio: Bool, displayName: String?, result: @escaping FlutterResult) {
|
func startRecording(withAudio: Bool, displayName: String?, result: @escaping FlutterResult) {
|
||||||
guard previewView != nil else {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,7 +308,9 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
|||||||
if let error {
|
if let error {
|
||||||
latestGallerySaved = false
|
latestGallerySaved = false
|
||||||
latestGalleryErrorMessage = error.localizedDescription
|
latestGalleryErrorMessage = error.localizedDescription
|
||||||
updateStatus(RecordingStatus(state: .error, outputPath: latestOutputPath, message: error.localizedDescription))
|
updateStatus(
|
||||||
|
RecordingStatus(
|
||||||
|
state: .error, outputPath: latestOutputPath, message: error.localizedDescription))
|
||||||
finishStopRecording(stopResult: stopResult)
|
finishStopRecording(stopResult: stopResult)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -411,10 +415,14 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let videoDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back)
|
guard
|
||||||
|
let videoDevice = AVCaptureDevice.default(
|
||||||
|
.builtInWideAngleCamera, for: .video, position: .back)
|
||||||
?? AVCaptureDevice.default(for: .video)
|
?? AVCaptureDevice.default(for: .video)
|
||||||
else {
|
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)
|
let nextVideoInput = try AVCaptureDeviceInput(device: videoDevice)
|
||||||
@@ -424,14 +432,18 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
|||||||
|
|
||||||
guard session.canAddInput(nextVideoInput) else {
|
guard session.canAddInput(nextVideoInput) else {
|
||||||
session.commitConfiguration()
|
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)
|
session.addInput(nextVideoInput)
|
||||||
videoInput = nextVideoInput
|
videoInput = nextVideoInput
|
||||||
|
|
||||||
guard session.canAddOutput(movieOutput) else {
|
guard session.canAddOutput(movieOutput) else {
|
||||||
session.commitConfiguration()
|
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.addOutput(movieOutput)
|
||||||
session.commitConfiguration()
|
session.commitConfiguration()
|
||||||
@@ -516,7 +528,7 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
|||||||
}
|
}
|
||||||
|
|
||||||
private enum RecordingChannelNames {
|
private enum RecordingChannelNames {
|
||||||
static let packageName = "com.gdfw.fxjk"
|
static let packageName = "com.qxy.dronex"
|
||||||
static let method = "\(packageName)/recording"
|
static let method = "\(packageName)/recording"
|
||||||
static let events = "\(packageName)/recording_events"
|
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
|
eventSink = events
|
||||||
events(controller.currentStatusMap())
|
events(controller.currentStatusMap())
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
@@ -18,12 +20,31 @@ class AppBootstrapper {
|
|||||||
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
|
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
|
||||||
|
|
||||||
await AppStorage.init();
|
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}');
|
AppLogger.debug('App started in ${AppConfig.current.environment.name}');
|
||||||
|
|
||||||
runApp(const ProviderScope(child: FlutterTemplateApp()));
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ class AppPlatformInfo {
|
|||||||
AppPlatformInfo._();
|
AppPlatformInfo._();
|
||||||
|
|
||||||
static const MethodChannel _channel = MethodChannel(
|
static const MethodChannel _channel = MethodChannel(
|
||||||
'com.gdfw.fxjk/platform_info',
|
'com.qxy.dronex/platform_info',
|
||||||
);
|
);
|
||||||
|
|
||||||
static Future<AppPackageInfo> packageInfo() async {
|
static Future<AppPackageInfo> packageInfo() async {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/// 小程序复制到剪切板的录制信息。
|
/// 小程序复制到剪切板的录制信息。
|
||||||
class ClipboardRecordingModel {
|
class ClipboardRecordingModel {
|
||||||
final String title;
|
final String title;
|
||||||
final int startTimestamp;
|
int? startTimestamp;
|
||||||
final int endTimestamp;
|
int? endTimestamp;
|
||||||
final String address;
|
final String address;
|
||||||
|
|
||||||
/// 录制文件名模板,如「选手名称_选手ID_赛事名称_赛项」。
|
/// 录制文件名模板,如「选手名称_选手ID_赛事名称_赛项」。
|
||||||
@@ -10,8 +10,8 @@ class ClipboardRecordingModel {
|
|||||||
|
|
||||||
ClipboardRecordingModel({
|
ClipboardRecordingModel({
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.startTimestamp,
|
this.startTimestamp,
|
||||||
required this.endTimestamp,
|
this.endTimestamp,
|
||||||
required this.address,
|
required this.address,
|
||||||
this.filename,
|
this.filename,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
abstract final class RecordingChannelNames {
|
abstract final class RecordingChannelNames {
|
||||||
static const packageName = 'com.gdfw.fxjk';
|
static const packageName = 'com.qxy.dronex';
|
||||||
static const method = '$packageName/recording';
|
static const method = '$packageName/recording';
|
||||||
static const events = '$packageName/recording_events';
|
static const events = '$packageName/recording_events';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,17 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
import 'package:permission_handler/permission_handler.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_platform.dart';
|
||||||
import 'package:recording_tool/features/recording/recording_session_controller.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/view-model/view_model_recording.dart';
|
||||||
import 'package:recording_tool/features/recording/widgets/camera_preview_widget.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/features/recording/widgets/recording_touch_lock_overlay.dart';
|
||||||
import 'package:recording_tool/shared/widgets/widgets.dart';
|
import 'package:recording_tool/shared/widgets/widgets.dart';
|
||||||
|
|
||||||
@@ -52,6 +56,69 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
|||||||
_immersiveApplied = true;
|
_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 {
|
Future<void> _exitRecordingMode() async {
|
||||||
if (!_immersiveApplied) return;
|
if (!_immersiveApplied) return;
|
||||||
await ref.read(recordingSessionControllerProvider.notifier).teardown();
|
await ref.read(recordingSessionControllerProvider.notifier).teardown();
|
||||||
@@ -100,6 +167,8 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
|||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
const CameraPreviewWidget(),
|
const CameraPreviewWidget(),
|
||||||
|
if (!state.isPreviewReady && state.errorMessage == null)
|
||||||
|
const _RecordingLoadingOverlay(message: '正在启动相机…'),
|
||||||
if (state.isTouchLocked && state.isRecording)
|
if (state.isTouchLocked && state.isRecording)
|
||||||
RecordingTouchLockOverlay(
|
RecordingTouchLockOverlay(
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -109,6 +178,7 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
|||||||
state: state,
|
state: state,
|
||||||
eventTitle: showClipboardInfo ? clipboard.title : null,
|
eventTitle: showClipboardInfo ? clipboard.title : null,
|
||||||
eventAddress: showClipboardInfo ? clipboard.address : null,
|
eventAddress: showClipboardInfo ? clipboard.address : null,
|
||||||
|
clipboardHintLabel: _clipboardHintLabel(recordingInfo),
|
||||||
onPasteEventInfo: () async {
|
onPasteEventInfo: () async {
|
||||||
final result = await ref
|
final result = await ref
|
||||||
.read(recordingViewModelProvider.notifier)
|
.read(recordingViewModelProvider.notifier)
|
||||||
@@ -125,7 +195,9 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
|||||||
final latest = ref.read(recordingSessionControllerProvider);
|
final latest = ref.read(recordingSessionControllerProvider);
|
||||||
if (latest.gallerySaveFailed) {
|
if (latest.gallerySaveFailed) {
|
||||||
AppToast.show(latest.errorMessage ?? '保存到相册失败,请开启相册权限');
|
AppToast.show(latest.errorMessage ?? '保存到相册失败,请开启相册权限');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
await _showRecordingSavedDialogIfNeeded();
|
||||||
},
|
},
|
||||||
onOpenDnd: () async {
|
onOpenDnd: () async {
|
||||||
await controller.openDndSettings();
|
await controller.openDndSettings();
|
||||||
@@ -139,6 +211,40 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
|||||||
controller.setTouchLocked(!state.isTouchLocked);
|
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,
|
required this.state,
|
||||||
this.eventTitle,
|
this.eventTitle,
|
||||||
this.eventAddress,
|
this.eventAddress,
|
||||||
|
this.clipboardHintLabel,
|
||||||
required this.onPasteEventInfo,
|
required this.onPasteEventInfo,
|
||||||
required this.onStart,
|
required this.onStart,
|
||||||
required this.onStop,
|
required this.onStop,
|
||||||
@@ -162,6 +269,7 @@ class _RecordingHud extends StatelessWidget {
|
|||||||
final RecordingSessionState state;
|
final RecordingSessionState state;
|
||||||
final String? eventTitle;
|
final String? eventTitle;
|
||||||
final String? eventAddress;
|
final String? eventAddress;
|
||||||
|
final String? clipboardHintLabel;
|
||||||
final Future<void> Function() onPasteEventInfo;
|
final Future<void> Function() onPasteEventInfo;
|
||||||
final VoidCallback onStart;
|
final VoidCallback onStart;
|
||||||
final VoidCallback onStop;
|
final VoidCallback onStop;
|
||||||
@@ -222,6 +330,7 @@ class _RecordingHud extends StatelessWidget {
|
|||||||
hasDndAccess: state.hasDndAccess,
|
hasDndAccess: state.hasDndAccess,
|
||||||
isBatteryIgnored: state.isBatteryOptimizedIgnored,
|
isBatteryIgnored: state.isBatteryOptimizedIgnored,
|
||||||
notificationsGranted: state.notificationsGranted,
|
notificationsGranted: state.notificationsGranted,
|
||||||
|
clipboardHintLabel: clipboardHintLabel,
|
||||||
onOpenDnd: onOpenDnd,
|
onOpenDnd: onOpenDnd,
|
||||||
onOpenBattery: onOpenBattery,
|
onOpenBattery: onOpenBattery,
|
||||||
onOpenNotificationSettings: openAppSettings,
|
onOpenNotificationSettings: openAppSettings,
|
||||||
@@ -249,13 +358,18 @@ class _RecordingHud extends StatelessWidget {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Center(
|
child: Center(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: state.isRecording ? onStop : onStart,
|
onTap: state.isStartingRecording
|
||||||
|
? null
|
||||||
|
: (state.isRecording ? onStop : onStart),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 76.w,
|
width: 76.w,
|
||||||
height: 76.h,
|
height: 76.h,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
border: Border.all(color: Colors.white, width: 4.r),
|
border: Border.all(
|
||||||
|
color: Colors.white,
|
||||||
|
width: 4.r,
|
||||||
|
),
|
||||||
color: state.isRecording
|
color: state.isRecording
|
||||||
? Colors.white
|
? Colors.white
|
||||||
: Colors.red,
|
: 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)
|
if (showPasteEventInfo)
|
||||||
@@ -324,9 +427,7 @@ class _RecordingHud extends StatelessWidget {
|
|||||||
left: 12.w,
|
left: 12.w,
|
||||||
right: 12.w,
|
right: 12.w,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.only(
|
padding: EdgeInsets.only(right: state.isRecording ? 96.w : 0),
|
||||||
right: state.isRecording ? 96.w : 0,
|
|
||||||
),
|
|
||||||
child: Text(
|
child: Text(
|
||||||
eventTitle!,
|
eventTitle!,
|
||||||
style: _overlayTextStyle.copyWith(
|
style: _overlayTextStyle.copyWith(
|
||||||
@@ -344,10 +445,7 @@ class _RecordingHud extends StatelessWidget {
|
|||||||
top: 8.r,
|
top: 8.r,
|
||||||
right: 12.w,
|
right: 12.w,
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: EdgeInsets.symmetric(
|
padding: EdgeInsets.symmetric(horizontal: 12.r, vertical: 6.r),
|
||||||
horizontal: 12.r,
|
|
||||||
vertical: 6.r,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.red,
|
color: Colors.red,
|
||||||
borderRadius: BorderRadius.circular(20.r),
|
borderRadius: BorderRadius.circular(20.r),
|
||||||
@@ -361,21 +459,21 @@ class _RecordingHud extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (eventAddress != null && eventAddress!.isNotEmpty)
|
// if (eventAddress != null && eventAddress!.isNotEmpty)
|
||||||
Positioned(
|
// Positioned(
|
||||||
left: 16.w,
|
// left: 16.w,
|
||||||
bottom: 108.r,
|
// bottom: 108.r,
|
||||||
right: 120.w,
|
// right: 120.w,
|
||||||
child: Text(
|
// child: Text(
|
||||||
eventAddress!,
|
// eventAddress!,
|
||||||
style: _overlayTextStyle.copyWith(
|
// style: _overlayTextStyle.copyWith(
|
||||||
fontSize: 13.sp,
|
// fontSize: 13.sp,
|
||||||
color: Colors.white70,
|
// color: Colors.white70,
|
||||||
),
|
// ),
|
||||||
maxLines: 2,
|
// maxLines: 2,
|
||||||
overflow: TextOverflow.ellipsis,
|
// overflow: TextOverflow.ellipsis,
|
||||||
),
|
// ),
|
||||||
),
|
// ),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -387,6 +485,7 @@ class _SetupHints extends StatelessWidget {
|
|||||||
required this.hasDndAccess,
|
required this.hasDndAccess,
|
||||||
required this.isBatteryIgnored,
|
required this.isBatteryIgnored,
|
||||||
required this.notificationsGranted,
|
required this.notificationsGranted,
|
||||||
|
this.clipboardHintLabel,
|
||||||
required this.onOpenDnd,
|
required this.onOpenDnd,
|
||||||
required this.onOpenBattery,
|
required this.onOpenBattery,
|
||||||
required this.onOpenNotificationSettings,
|
required this.onOpenNotificationSettings,
|
||||||
@@ -395,13 +494,18 @@ class _SetupHints extends StatelessWidget {
|
|||||||
final bool hasDndAccess;
|
final bool hasDndAccess;
|
||||||
final bool isBatteryIgnored;
|
final bool isBatteryIgnored;
|
||||||
final bool notificationsGranted;
|
final bool notificationsGranted;
|
||||||
|
final String? clipboardHintLabel;
|
||||||
final VoidCallback onOpenDnd;
|
final VoidCallback onOpenDnd;
|
||||||
final VoidCallback onOpenBattery;
|
final VoidCallback onOpenBattery;
|
||||||
final VoidCallback onOpenNotificationSettings;
|
final VoidCallback onOpenNotificationSettings;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,6 +526,11 @@ class _SetupHints extends StatelessWidget {
|
|||||||
SizedBox(height: 8.h),
|
SizedBox(height: 8.h),
|
||||||
_HintChip(label: '关闭电池优化可提升息屏续录稳定性', onTap: onOpenBattery),
|
_HintChip(label: '关闭电池优化可提升息屏续录稳定性', onTap: onOpenBattery),
|
||||||
],
|
],
|
||||||
|
if (clipboardHintLabel != null &&
|
||||||
|
clipboardHintLabel!.isNotEmpty) ...[
|
||||||
|
SizedBox(height: 8.h),
|
||||||
|
_HintChip(label: clipboardHintLabel!, onTap: () {}),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class RecordingSessionState {
|
|||||||
this.status = const RecordingStatus(state: RecordingState.idle),
|
this.status = const RecordingStatus(state: RecordingState.idle),
|
||||||
this.isTouchLocked = true,
|
this.isTouchLocked = true,
|
||||||
this.isPreviewReady = false,
|
this.isPreviewReady = false,
|
||||||
|
this.isStartingRecording = false,
|
||||||
this.hasDndAccess = false,
|
this.hasDndAccess = false,
|
||||||
this.isBatteryOptimizedIgnored = true,
|
this.isBatteryOptimizedIgnored = true,
|
||||||
this.notificationsGranted = true,
|
this.notificationsGranted = true,
|
||||||
@@ -28,6 +29,7 @@ class RecordingSessionState {
|
|||||||
final RecordingStatus status;
|
final RecordingStatus status;
|
||||||
final bool isTouchLocked;
|
final bool isTouchLocked;
|
||||||
final bool isPreviewReady;
|
final bool isPreviewReady;
|
||||||
|
final bool isStartingRecording;
|
||||||
final bool hasDndAccess;
|
final bool hasDndAccess;
|
||||||
final bool isBatteryOptimizedIgnored;
|
final bool isBatteryOptimizedIgnored;
|
||||||
final bool notificationsGranted;
|
final bool notificationsGranted;
|
||||||
@@ -51,6 +53,7 @@ class RecordingSessionState {
|
|||||||
RecordingStatus? status,
|
RecordingStatus? status,
|
||||||
bool? isTouchLocked,
|
bool? isTouchLocked,
|
||||||
bool? isPreviewReady,
|
bool? isPreviewReady,
|
||||||
|
bool? isStartingRecording,
|
||||||
bool? hasDndAccess,
|
bool? hasDndAccess,
|
||||||
bool? isBatteryOptimizedIgnored,
|
bool? isBatteryOptimizedIgnored,
|
||||||
bool? notificationsGranted,
|
bool? notificationsGranted,
|
||||||
@@ -67,6 +70,7 @@ class RecordingSessionState {
|
|||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
isTouchLocked: isTouchLocked ?? this.isTouchLocked,
|
isTouchLocked: isTouchLocked ?? this.isTouchLocked,
|
||||||
isPreviewReady: isPreviewReady ?? this.isPreviewReady,
|
isPreviewReady: isPreviewReady ?? this.isPreviewReady,
|
||||||
|
isStartingRecording: isStartingRecording ?? this.isStartingRecording,
|
||||||
hasDndAccess: hasDndAccess ?? this.hasDndAccess,
|
hasDndAccess: hasDndAccess ?? this.hasDndAccess,
|
||||||
isBatteryOptimizedIgnored:
|
isBatteryOptimizedIgnored:
|
||||||
isBatteryOptimizedIgnored ?? this.isBatteryOptimizedIgnored,
|
isBatteryOptimizedIgnored ?? this.isBatteryOptimizedIgnored,
|
||||||
@@ -204,11 +208,16 @@ class RecordingSessionController extends Notifier<RecordingSessionState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> startRecording({bool enableDoNotDisturb = true}) async {
|
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 clipboard = ref.read(recordingViewModelProvider).clipboardRecordingModel;
|
||||||
final displayName = recordingFileNameForPlatform(clipboard.filename);
|
final displayName = recordingFileNameForPlatform(clipboard.filename);
|
||||||
|
|
||||||
|
state = state.copyWith(isStartingRecording: true, errorMessage: null);
|
||||||
try {
|
try {
|
||||||
final result = await RecordingPlatform.startRecording(
|
final result = await RecordingPlatform.startRecording(
|
||||||
enableDoNotDisturb: enableDoNotDisturb && state.hasDndAccess,
|
enableDoNotDisturb: enableDoNotDisturb && state.hasDndAccess,
|
||||||
@@ -224,6 +233,8 @@ class RecordingSessionController extends Notifier<RecordingSessionState> {
|
|||||||
);
|
);
|
||||||
} on PlatformException catch (error) {
|
} on PlatformException catch (error) {
|
||||||
state = state.copyWith(errorMessage: error.message ?? '开始录制失败');
|
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);
|
state = state.copyWith(isTouchLocked: locked);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void clearSavedRecordingResult() {
|
||||||
|
state = state.copyWith(clearLastSaved: true);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> openDndSettings() =>
|
Future<void> openDndSettings() =>
|
||||||
RecordingPlatform.openNotificationPolicySettings();
|
RecordingPlatform.openNotificationPolicySettings();
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,10 @@ class RecordingViewModel extends StateNotifier<RecordingModel> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void resetClipboardInfo() {
|
||||||
|
_resetClipboardInfo();
|
||||||
|
}
|
||||||
|
|
||||||
void _resetClipboardInfo() {
|
void _resetClipboardInfo() {
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
clipboardRecordingModel: _defaultClipboard,
|
clipboardRecordingModel: _defaultClipboard,
|
||||||
|
|||||||
120
lib/features/recording/widgets/recording_saved_dialog.dart
Normal file
120
lib/features/recording/widgets/recording_saved_dialog.dart
Normal 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)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user