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" />
|
||||||
@@ -32,12 +32,12 @@
|
|||||||
android:hardwareAccelerated="true"
|
android:hardwareAccelerated="true"
|
||||||
android:windowSoftInputMode="adjustResize">
|
android:windowSoftInputMode="adjustResize">
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="io.flutter.embedding.android.NormalTheme"
|
android:name="io.flutter.embedding.android.NormalTheme"
|
||||||
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,33 +18,31 @@ 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),
|
||||||
)
|
)
|
||||||
|
|
||||||
platformInfoChannel =
|
platformInfoChannel =
|
||||||
MethodChannel(
|
MethodChannel(
|
||||||
flutterEngine.dartExecutor.binaryMessenger,
|
flutterEngine.dartExecutor.binaryMessenger,
|
||||||
AppConstants.PLATFORM_INFO_CHANNEL,
|
AppConstants.PLATFORM_INFO_CHANNEL,
|
||||||
).also { channel ->
|
)
|
||||||
channel.setMethodCallHandler { call, result ->
|
.also { channel ->
|
||||||
when (call.method) {
|
channel.setMethodCallHandler { call, result ->
|
||||||
"packageInfo" -> result.success(packageInfoMap())
|
when (call.method) {
|
||||||
"deviceInfo" -> result.success(deviceInfoMap())
|
"packageInfo" -> result.success(packageInfoMap())
|
||||||
else -> result.notImplemented()
|
"deviceInfo" -> result.success(deviceInfoMap())
|
||||||
}
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
platformHandler =
|
platformHandler =
|
||||||
RecordingPlatformHandler(
|
RecordingPlatformHandler(
|
||||||
this,
|
this,
|
||||||
flutterEngine.dartExecutor.binaryMessenger,
|
flutterEngine.dartExecutor.binaryMessenger,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun attachRecordingPreview(previewView: PreviewView) {
|
fun attachRecordingPreview(previewView: PreviewView) {
|
||||||
@@ -67,54 +65,51 @@ class MainActivity : FlutterActivity() {
|
|||||||
|
|
||||||
private fun packageInfoMap(): Map<String, String> {
|
private fun packageInfoMap(): Map<String, String> {
|
||||||
val packageInfo =
|
val packageInfo =
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
packageManager.getPackageInfo(
|
packageManager.getPackageInfo(
|
||||||
packageName,
|
packageName,
|
||||||
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(
|
||||||
"appName" to appName,
|
"appName" to appName,
|
||||||
"packageName" to packageName,
|
"packageName" to packageName,
|
||||||
"version" to packageInfo.versionName.orEmpty(),
|
"version" to packageInfo.versionName.orEmpty(),
|
||||||
"buildNumber" to versionCode,
|
"buildNumber" to versionCode,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun deviceInfoMap(): Map<String, Any> {
|
private fun deviceInfoMap(): Map<String, Any> {
|
||||||
val flags = applicationInfo.flags
|
val flags = applicationInfo.flags
|
||||||
val isEmulator =
|
val isEmulator =
|
||||||
Build.FINGERPRINT.startsWith("generic") ||
|
Build.FINGERPRINT.startsWith("generic") ||
|
||||||
Build.FINGERPRINT.startsWith("unknown") ||
|
Build.FINGERPRINT.startsWith("unknown") ||
|
||||||
Build.MODEL.contains("google_sdk") ||
|
Build.MODEL.contains("google_sdk") ||
|
||||||
Build.MODEL.contains("Emulator") ||
|
Build.MODEL.contains("Emulator") ||
|
||||||
Build.MODEL.contains("Android SDK built for x86") ||
|
Build.MODEL.contains("Android SDK built for x86") ||
|
||||||
Build.MANUFACTURER.contains("Genymotion") ||
|
Build.MANUFACTURER.contains("Genymotion") ||
|
||||||
Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic") ||
|
Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic") ||
|
||||||
Build.PRODUCT == "google_sdk" ||
|
Build.PRODUCT == "google_sdk" ||
|
||||||
flags and ApplicationInfo.FLAG_DEBUGGABLE != 0 &&
|
flags and ApplicationInfo.FLAG_DEBUGGABLE != 0 &&
|
||||||
Build.HARDWARE.contains("ranchu")
|
Build.HARDWARE.contains("ranchu")
|
||||||
|
|
||||||
return mapOf(
|
return mapOf(
|
||||||
"platform" to "android",
|
"platform" to "android",
|
||||||
"brand" to Build.BRAND,
|
"brand" to Build.BRAND,
|
||||||
"model" to Build.MODEL,
|
"model" to Build.MODEL,
|
||||||
"systemVersion" to Build.VERSION.RELEASE,
|
"systemVersion" to Build.VERSION.RELEASE,
|
||||||
"isPhysicalDevice" to !isEmulator,
|
"isPhysicalDevice" to !isEmulator,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -18,10 +18,10 @@ object BatteryOptimizationHelper {
|
|||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return
|
||||||
|
|
||||||
val intent =
|
val intent =
|
||||||
Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
|
Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
|
||||||
data = Uri.parse("package:${context.packageName}")
|
data = Uri.parse("package:${context.packageName}")
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (intent.resolveActivity(context.packageManager) != null) {
|
if (intent.resolveActivity(context.packageManager) != null) {
|
||||||
context.startActivity(intent)
|
context.startActivity(intent)
|
||||||
@@ -29,9 +29,9 @@ object BatteryOptimizationHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val fallback =
|
val fallback =
|
||||||
Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS).apply {
|
Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS).apply {
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
}
|
}
|
||||||
context.startActivity(fallback)
|
context.startActivity(fallback)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,9 +15,10 @@ object DoNotDisturbHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun openAccessSettings(context: Context) {
|
fun openAccessSettings(context: Context) {
|
||||||
val intent = Intent(Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS).apply {
|
val intent =
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
Intent(Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS).apply {
|
||||||
}
|
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
|
||||||
@@ -17,7 +17,7 @@ import androidx.lifecycle.LifecycleOwner
|
|||||||
import java.util.concurrent.Executor
|
import java.util.concurrent.Executor
|
||||||
|
|
||||||
class RecordingCameraController(
|
class RecordingCameraController(
|
||||||
private val appContext: Context,
|
private val appContext: Context,
|
||||||
) {
|
) {
|
||||||
private val mainExecutor: Executor = ContextCompat.getMainExecutor(appContext)
|
private val mainExecutor: Executor = ContextCompat.getMainExecutor(appContext)
|
||||||
|
|
||||||
@@ -37,58 +37,58 @@ class RecordingCameraController(
|
|||||||
private var pendingStopCallback: ((String?) -> Unit)? = null
|
private var pendingStopCallback: ((String?) -> Unit)? = null
|
||||||
|
|
||||||
fun bindPreview(
|
fun bindPreview(
|
||||||
lifecycleOwner: LifecycleOwner,
|
lifecycleOwner: LifecycleOwner,
|
||||||
previewView: PreviewView,
|
previewView: PreviewView,
|
||||||
onReady: (Boolean) -> Unit,
|
onReady: (Boolean) -> Unit,
|
||||||
) {
|
) {
|
||||||
val future = ProcessCameraProvider.getInstance(appContext)
|
val future = ProcessCameraProvider.getInstance(appContext)
|
||||||
future.addListener(
|
future.addListener(
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
val provider = future.get()
|
val provider = future.get()
|
||||||
cameraProvider = provider
|
cameraProvider = provider
|
||||||
boundLifecycleOwner = lifecycleOwner
|
boundLifecycleOwner = lifecycleOwner
|
||||||
|
|
||||||
preview =
|
preview =
|
||||||
Preview.Builder().build().also {
|
Preview.Builder().build().also {
|
||||||
it.surfaceProvider = previewView.surfaceProvider
|
it.surfaceProvider = previewView.surfaceProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
val recorder =
|
val recorder =
|
||||||
Recorder.Builder()
|
Recorder.Builder()
|
||||||
.setQualitySelector(QualitySelector.from(Quality.HD))
|
.setQualitySelector(QualitySelector.from(Quality.HD))
|
||||||
.build()
|
.build()
|
||||||
videoCapture = VideoCapture.withOutput(recorder)
|
videoCapture = VideoCapture.withOutput(recorder)
|
||||||
|
|
||||||
provider.unbindAll()
|
provider.unbindAll()
|
||||||
provider.bindToLifecycle(
|
provider.bindToLifecycle(
|
||||||
lifecycleOwner,
|
lifecycleOwner,
|
||||||
CameraSelector.DEFAULT_BACK_CAMERA,
|
CameraSelector.DEFAULT_BACK_CAMERA,
|
||||||
preview,
|
preview,
|
||||||
videoCapture,
|
videoCapture,
|
||||||
)
|
)
|
||||||
|
|
||||||
updateStatus(RecordingStatus(RecordingState.PREVIEWING))
|
updateStatus(RecordingStatus(RecordingState.PREVIEWING))
|
||||||
onReady(true)
|
onReady(true)
|
||||||
} catch (error: Exception) {
|
} catch (error: Exception) {
|
||||||
Log.e(TAG, "bindPreview failed", error)
|
Log.e(TAG, "bindPreview failed", error)
|
||||||
updateStatus(
|
updateStatus(
|
||||||
RecordingStatus(
|
RecordingStatus(
|
||||||
RecordingState.ERROR,
|
RecordingState.ERROR,
|
||||||
message = error.message,
|
message = error.message,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
onReady(false)
|
onReady(false)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mainExecutor,
|
mainExecutor,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun rebindForRecording(
|
fun rebindForRecording(
|
||||||
lifecycleOwner: LifecycleOwner,
|
lifecycleOwner: LifecycleOwner,
|
||||||
previewView: PreviewView,
|
previewView: PreviewView,
|
||||||
onReady: (Boolean) -> Unit,
|
onReady: (Boolean) -> Unit,
|
||||||
) {
|
) {
|
||||||
val provider = cameraProvider
|
val provider = cameraProvider
|
||||||
if (provider == null) {
|
if (provider == null) {
|
||||||
@@ -100,10 +100,10 @@ class RecordingCameraController(
|
|||||||
boundLifecycleOwner = lifecycleOwner
|
boundLifecycleOwner = lifecycleOwner
|
||||||
provider.unbindAll()
|
provider.unbindAll()
|
||||||
provider.bindToLifecycle(
|
provider.bindToLifecycle(
|
||||||
lifecycleOwner,
|
lifecycleOwner,
|
||||||
CameraSelector.DEFAULT_BACK_CAMERA,
|
CameraSelector.DEFAULT_BACK_CAMERA,
|
||||||
preview,
|
preview,
|
||||||
videoCapture,
|
videoCapture,
|
||||||
)
|
)
|
||||||
onReady(true)
|
onReady(true)
|
||||||
} catch (error: Exception) {
|
} catch (error: Exception) {
|
||||||
@@ -113,9 +113,9 @@ class RecordingCameraController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun startRecording(
|
fun startRecording(
|
||||||
withAudio: Boolean,
|
withAudio: Boolean,
|
||||||
displayName: String?,
|
displayName: String?,
|
||||||
onStarted: (Boolean, String?) -> Unit,
|
onStarted: (Boolean, String?) -> Unit,
|
||||||
) {
|
) {
|
||||||
val capture = videoCapture
|
val capture = videoCapture
|
||||||
if (capture == null || boundLifecycleOwner == null) {
|
if (capture == null || boundLifecycleOwner == null) {
|
||||||
@@ -129,63 +129,66 @@ class RecordingCameraController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val outputOptions =
|
val outputOptions =
|
||||||
RecordingOutputFactory.buildMediaStoreOutputOptions(
|
RecordingOutputFactory.buildMediaStoreOutputOptions(
|
||||||
appContext,
|
appContext,
|
||||||
displayName,
|
displayName,
|
||||||
)
|
)
|
||||||
latestOutputPath = null
|
latestOutputPath = null
|
||||||
|
|
||||||
val pending =
|
val pending =
|
||||||
capture.output.prepareRecording(appContext, outputOptions).apply {
|
capture.output.prepareRecording(appContext, outputOptions).apply {
|
||||||
if (withAudio) {
|
if (withAudio) {
|
||||||
val granted =
|
val granted =
|
||||||
ContextCompat.checkSelfPermission(
|
ContextCompat.checkSelfPermission(
|
||||||
appContext,
|
appContext,
|
||||||
android.Manifest.permission.RECORD_AUDIO,
|
android.Manifest.permission.RECORD_AUDIO,
|
||||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||||
if (granted) {
|
if (granted) {
|
||||||
withAudioEnabled()
|
withAudioEnabled()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
recordingStartedAt = System.currentTimeMillis()
|
recordingStartedAt = System.currentTimeMillis()
|
||||||
updateStatus(
|
updateStatus(
|
||||||
RecordingStatus(
|
RecordingStatus(
|
||||||
RecordingState.RECORDING,
|
RecordingState.RECORDING,
|
||||||
outputPath = latestOutputPath,
|
outputPath = latestOutputPath,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
activeRecording =
|
activeRecording =
|
||||||
pending.start(mainExecutor) { event ->
|
pending.start(mainExecutor) { event ->
|
||||||
when (event) {
|
when (event) {
|
||||||
is VideoRecordEvent.Start -> Unit
|
is VideoRecordEvent.Start -> Unit
|
||||||
is VideoRecordEvent.Finalize -> {
|
is VideoRecordEvent.Finalize -> {
|
||||||
activeRecording = null
|
activeRecording = null
|
||||||
if (event.hasError()) {
|
if (event.hasError()) {
|
||||||
updateStatus(
|
updateStatus(
|
||||||
RecordingStatus(
|
RecordingStatus(
|
||||||
RecordingState.ERROR,
|
RecordingState.ERROR,
|
||||||
message = event.cause?.message ?: "Recording failed",
|
message = event.cause?.message
|
||||||
),
|
?: "Recording failed",
|
||||||
)
|
),
|
||||||
} else {
|
)
|
||||||
latestOutputPath = event.outputResults.outputUri.toString()
|
} else {
|
||||||
updateStatus(
|
latestOutputPath = event.outputResults.outputUri.toString()
|
||||||
RecordingStatus(
|
updateStatus(
|
||||||
RecordingState.PREVIEWING,
|
RecordingStatus(
|
||||||
outputPath = latestOutputPath,
|
RecordingState.PREVIEWING,
|
||||||
elapsedMillis = System.currentTimeMillis() - recordingStartedAt,
|
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")
|
onStarted(true, latestOutputPath ?: "recording")
|
||||||
}
|
}
|
||||||
@@ -199,10 +202,10 @@ class RecordingCameraController(
|
|||||||
|
|
||||||
pendingStopCallback = onStopped
|
pendingStopCallback = onStopped
|
||||||
updateStatus(
|
updateStatus(
|
||||||
RecordingStatus(
|
RecordingStatus(
|
||||||
RecordingState.STOPPING,
|
RecordingState.STOPPING,
|
||||||
outputPath = latestOutputPath,
|
outputPath = latestOutputPath,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
recording.stop()
|
recording.stop()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -35,9 +34,9 @@ class RecordingForegroundService : LifecycleService() {
|
|||||||
val notification = buildNotification("正在录制")
|
val notification = buildNotification("正在录制")
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
startForeground(
|
startForeground(
|
||||||
NOTIFICATION_ID,
|
NOTIFICATION_ID,
|
||||||
notification,
|
notification,
|
||||||
foregroundServiceTypes(),
|
foregroundServiceTypes(),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
startForeground(NOTIFICATION_ID, notification)
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
@@ -72,10 +71,10 @@ class RecordingForegroundService : LifecycleService() {
|
|||||||
if (wakeLock?.isHeld == true) return
|
if (wakeLock?.isHeld == true) return
|
||||||
val manager = getSystemService(PowerManager::class.java) ?: return
|
val manager = getSystemService(PowerManager::class.java) ?: return
|
||||||
wakeLock =
|
wakeLock =
|
||||||
manager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).apply {
|
manager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).apply {
|
||||||
setReferenceCounted(false)
|
setReferenceCounted(false)
|
||||||
acquire(4 * 60 * 60 * 1000L)
|
acquire(4 * 60 * 60 * 1000L)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun releaseWakeLock() {
|
private fun releaseWakeLock() {
|
||||||
@@ -90,14 +89,15 @@ class RecordingForegroundService : LifecycleService() {
|
|||||||
private fun createNotificationChannel() {
|
private fun createNotificationChannel() {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
val channel =
|
val channel =
|
||||||
NotificationChannel(
|
NotificationChannel(
|
||||||
CHANNEL_ID,
|
CHANNEL_ID,
|
||||||
"录制服务",
|
"录制服务",
|
||||||
NotificationManager.IMPORTANCE_LOW,
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
).apply {
|
)
|
||||||
description = "保持相机录制在后台与息屏时继续运行"
|
.apply {
|
||||||
setShowBadge(false)
|
description = "保持相机录制在后台与息屏时继续运行"
|
||||||
}
|
setShowBadge(false)
|
||||||
|
}
|
||||||
val manager = getSystemService(NotificationManager::class.java)
|
val manager = getSystemService(NotificationManager::class.java)
|
||||||
manager?.createNotificationChannel(channel)
|
manager?.createNotificationChannel(channel)
|
||||||
}
|
}
|
||||||
@@ -112,33 +112,33 @@ class RecordingForegroundService : LifecycleService() {
|
|||||||
|
|
||||||
private fun hasRecordAudioPermission(): Boolean {
|
private fun hasRecordAudioPermission(): Boolean {
|
||||||
return ContextCompat.checkSelfPermission(
|
return ContextCompat.checkSelfPermission(
|
||||||
this,
|
this,
|
||||||
android.Manifest.permission.RECORD_AUDIO,
|
android.Manifest.permission.RECORD_AUDIO,
|
||||||
) == PackageManager.PERMISSION_GRANTED
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildNotification(content: String): Notification {
|
private fun buildNotification(content: String): Notification {
|
||||||
val launchIntent =
|
val launchIntent =
|
||||||
Intent(this, MainActivity::class.java).apply {
|
Intent(this, MainActivity::class.java).apply {
|
||||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||||
}
|
}
|
||||||
val pendingIntent =
|
val pendingIntent =
|
||||||
PendingIntent.getActivity(
|
PendingIntent.getActivity(
|
||||||
this,
|
this,
|
||||||
0,
|
0,
|
||||||
launchIntent,
|
launchIntent,
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
)
|
)
|
||||||
|
|
||||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
.setContentTitle("录制进行中")
|
.setContentTitle("录制进行中")
|
||||||
.setContentText(content)
|
.setContentText(content)
|
||||||
.setSmallIcon(android.R.drawable.presence_video_online)
|
.setSmallIcon(android.R.drawable.presence_video_online)
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.setContentIntent(pendingIntent)
|
.setContentIntent(pendingIntent)
|
||||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -146,25 +146,23 @@ 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 =
|
||||||
Intent(context, RecordingForegroundService::class.java).apply {
|
Intent(context, RecordingForegroundService::class.java).apply {
|
||||||
action = AppConstants.RECORDING_ACTION_START
|
action = AppConstants.RECORDING_ACTION_START
|
||||||
}
|
}
|
||||||
ContextCompatStart.startForegroundService(context, intent)
|
ContextCompatStart.startForegroundService(context, intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stop(context: Context) {
|
fun stop(context: Context) {
|
||||||
val intent =
|
val intent =
|
||||||
Intent(context, RecordingForegroundService::class.java).apply {
|
Intent(context, RecordingForegroundService::class.java).apply {
|
||||||
action = AppConstants.RECORDING_ACTION_STOP
|
action = AppConstants.RECORDING_ACTION_STOP
|
||||||
}
|
}
|
||||||
context.startService(intent)
|
context.startService(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
|
||||||
@@ -14,25 +14,25 @@ object RecordingOutputFactory {
|
|||||||
private const val MIME_TYPE = "video/mp4"
|
private const val MIME_TYPE = "video/mp4"
|
||||||
|
|
||||||
fun buildMediaStoreOutputOptions(
|
fun buildMediaStoreOutputOptions(
|
||||||
context: Context,
|
context: Context,
|
||||||
displayName: String?,
|
displayName: String?,
|
||||||
): MediaStoreOutputOptions {
|
): MediaStoreOutputOptions {
|
||||||
val fileName = resolveFileName(displayName)
|
val fileName = resolveFileName(displayName)
|
||||||
val contentValues =
|
val contentValues =
|
||||||
ContentValues().apply {
|
ContentValues().apply {
|
||||||
put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
|
put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
|
||||||
put(MediaStore.MediaColumns.MIME_TYPE, MIME_TYPE)
|
put(MediaStore.MediaColumns.MIME_TYPE, MIME_TYPE)
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
put(MediaStore.Video.Media.RELATIVE_PATH, RELATIVE_PATH)
|
put(MediaStore.Video.Media.RELATIVE_PATH, RELATIVE_PATH)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return MediaStoreOutputOptions.Builder(
|
return MediaStoreOutputOptions.Builder(
|
||||||
context.contentResolver,
|
context.contentResolver,
|
||||||
MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
|
MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
|
||||||
)
|
)
|
||||||
.setContentValues(contentValues)
|
.setContentValues(contentValues)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun resolveFileName(displayName: String?): String {
|
fun resolveFileName(displayName: String?): String {
|
||||||
@@ -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,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.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
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
|
||||||
class RecordingPlatformHandler(
|
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,20 +54,18 @@ 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)
|
||||||
}
|
}
|
||||||
"isIgnoringBatteryOptimizations" ->
|
"isIgnoringBatteryOptimizations" ->
|
||||||
result.success(BatteryOptimizationHelper.isIgnoringOptimizations(activity))
|
result.success(BatteryOptimizationHelper.isIgnoringOptimizations(activity))
|
||||||
"openBatteryOptimizationSettings" -> {
|
"openBatteryOptimizationSettings" -> {
|
||||||
BatteryOptimizationHelper.openSettings(activity)
|
BatteryOptimizationHelper.openSettings(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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,10 +100,10 @@ class RecordingPlatformHandler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun startRecording(
|
private fun startRecording(
|
||||||
withAudio: Boolean,
|
withAudio: Boolean,
|
||||||
enableDnd: Boolean,
|
enableDnd: Boolean,
|
||||||
displayName: String?,
|
displayName: String?,
|
||||||
result: MethodChannel.Result,
|
result: MethodChannel.Result,
|
||||||
) {
|
) {
|
||||||
val previewView = activity.recordingPreviewView
|
val previewView = activity.recordingPreviewView
|
||||||
if (previewView == null) {
|
if (previewView == null) {
|
||||||
@@ -132,10 +123,10 @@ class RecordingPlatformHandler(
|
|||||||
if (started) {
|
if (started) {
|
||||||
startElapsedTicker()
|
startElapsedTicker()
|
||||||
result.success(
|
result.success(
|
||||||
mapOf(
|
mapOf(
|
||||||
"outputPath" to message,
|
"outputPath" to message,
|
||||||
"status" to controller.status.toMap(),
|
"status" to controller.status.toMap(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
RecordingSession.stopForeground(activity)
|
RecordingSession.stopForeground(activity)
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -196,7 +184,7 @@ class RecordingPlatformHandler(
|
|||||||
if (enabled) {
|
if (enabled) {
|
||||||
insetsController.hide(WindowInsetsCompat.Type.systemBars())
|
insetsController.hide(WindowInsetsCompat.Type.systemBars())
|
||||||
insetsController.systemBarsBehavior =
|
insetsController.systemBarsBehavior =
|
||||||
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||||
} else {
|
} else {
|
||||||
insetsController.show(WindowInsetsCompat.Type.systemBars())
|
insetsController.show(WindowInsetsCompat.Type.systemBars())
|
||||||
}
|
}
|
||||||
@@ -205,20 +193,23 @@ class RecordingPlatformHandler(
|
|||||||
private fun startElapsedTicker() {
|
private fun startElapsedTicker() {
|
||||||
stopElapsedTicker()
|
stopElapsedTicker()
|
||||||
elapsedTicker =
|
elapsedTicker =
|
||||||
object : Runnable {
|
object : Runnable {
|
||||||
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 =
|
||||||
mainHandler.postDelayed(this, 1000L)
|
controller.elapsedMillis(),
|
||||||
}
|
)
|
||||||
}
|
.toMap(),
|
||||||
}.also {
|
)
|
||||||
mainHandler.post(it)
|
mainHandler.postDelayed(this, 1000L)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.also { mainHandler.post(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun stopElapsedTicker() {
|
private fun stopElapsedTicker() {
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
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
|
||||||
|
|
||||||
class RecordingPreviewFactory(
|
class RecordingPreviewFactory(
|
||||||
private val activity: MainActivity,
|
private val activity: MainActivity,
|
||||||
) : PlatformViewFactory(StandardMessageCodec.INSTANCE) {
|
) : PlatformViewFactory(StandardMessageCodec.INSTANCE) {
|
||||||
override fun create(context: Context, viewId: Int, args: Any?): PlatformView {
|
override fun create(context: Context, viewId: Int, args: Any?): PlatformView {
|
||||||
return RecordingPreviewPlatformView(activity)
|
return RecordingPreviewPlatformView(activity)
|
||||||
@@ -17,13 +17,13 @@ class RecordingPreviewFactory(
|
|||||||
}
|
}
|
||||||
|
|
||||||
class RecordingPreviewPlatformView(
|
class RecordingPreviewPlatformView(
|
||||||
private val activity: MainActivity,
|
private val activity: MainActivity,
|
||||||
) : PlatformView {
|
) : PlatformView {
|
||||||
val previewView: PreviewView =
|
val previewView: PreviewView =
|
||||||
PreviewView(activity).apply {
|
PreviewView(activity).apply {
|
||||||
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
|
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
|
||||||
scaleType = PreviewView.ScaleType.FILL_CENTER
|
scaleType = PreviewView.ScaleType.FILL_CENTER
|
||||||
}
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
activity.attachRecordingPreview(previewView)
|
activity.attachRecordingPreview(previewView)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -8,9 +8,9 @@ object RecordingSession {
|
|||||||
|
|
||||||
fun controller(context: Context): RecordingCameraController {
|
fun controller(context: Context): RecordingCameraController {
|
||||||
return cameraController
|
return cameraController
|
||||||
?: RecordingCameraController(context.applicationContext).also {
|
?: RecordingCameraController(context.applicationContext).also {
|
||||||
cameraController = it
|
cameraController = it
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun release() {
|
fun release() {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.gdfw.fxjk.recording
|
package com.qxy.dronex.recording
|
||||||
|
|
||||||
enum class RecordingState {
|
enum class RecordingState {
|
||||||
IDLE,
|
IDLE,
|
||||||
@@ -9,16 +9,16 @@ enum class RecordingState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
data class RecordingStatus(
|
data class RecordingStatus(
|
||||||
val state: RecordingState,
|
val state: RecordingState,
|
||||||
val outputPath: String? = null,
|
val outputPath: String? = null,
|
||||||
val elapsedMillis: Long = 0L,
|
val elapsedMillis: Long = 0L,
|
||||||
val message: String? = null,
|
val message: String? = null,
|
||||||
) {
|
) {
|
||||||
fun toMap(): Map<String, Any?> =
|
fun toMap(): Map<String, Any?> =
|
||||||
mapOf(
|
mapOf(
|
||||||
"state" to state.name.lowercase(),
|
"state" to state.name.lowercase(),
|
||||||
"outputPath" to outputPath,
|
"outputPath" to outputPath,
|
||||||
"elapsedMillis" to elapsedMillis,
|
"elapsedMillis" to elapsedMillis,
|
||||||
"message" to message,
|
"message" to message,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
?? AVCaptureDevice.default(for: .video)
|
let videoDevice = AVCaptureDevice.default(
|
||||||
|
.builtInWideAngleCamera, for: .video, position: .back)
|
||||||
|
?? 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