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