1.确定 APP 包名

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

View File

@@ -4,7 +4,7 @@ plugins {
id("dev.flutter.flutter-gradle-plugin") id("dev.flutter.flutter-gradle-plugin")
} }
val appPackageName = "com.gdfw.fxjk" val appPackageName = "com.qxy.dronex"
android { android {
namespace = appPackageName namespace = appPackageName

View File

@@ -1,5 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.gdfw.fxjk"> package="com.qxy.dronex">
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
@@ -36,8 +36,8 @@
android:resource="@style/NormalTheme" android:resource="@style/NormalTheme"
/> />
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
@@ -52,8 +52,8 @@
</application> </application>
<queries> <queries>
<intent> <intent>
<action android:name="android.intent.action.PROCESS_TEXT"/> <action android:name="android.intent.action.PROCESS_TEXT" />
<data android:mimeType="text/plain"/> <data android:mimeType="text/plain" />
</intent> </intent>
</queries> </queries>
</manifest> </manifest>

View File

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

View File

@@ -1,10 +1,10 @@
package com.gdfw.fxjk package com.qxy.dronex
import android.content.pm.ApplicationInfo import android.content.pm.ApplicationInfo
import android.os.Build import android.os.Build
import androidx.camera.view.PreviewView import androidx.camera.view.PreviewView
import com.gdfw.fxjk.recording.RecordingPlatformHandler import com.qxy.dronex.recording.RecordingPlatformHandler
import com.gdfw.fxjk.recording.RecordingPreviewFactory import com.qxy.dronex.recording.RecordingPreviewFactory
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
@@ -18,10 +18,7 @@ class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
flutterEngine flutterEngine.platformViewsController.registry.registerViewFactory(
.platformViewsController
.registry
.registerViewFactory(
"recording-camera-preview", "recording-camera-preview",
RecordingPreviewFactory(this), RecordingPreviewFactory(this),
) )
@@ -30,7 +27,8 @@ class MainActivity : FlutterActivity() {
MethodChannel( MethodChannel(
flutterEngine.dartExecutor.binaryMessenger, flutterEngine.dartExecutor.binaryMessenger,
AppConstants.PLATFORM_INFO_CHANNEL, AppConstants.PLATFORM_INFO_CHANNEL,
).also { channel -> )
.also { channel ->
channel.setMethodCallHandler { call, result -> channel.setMethodCallHandler { call, result ->
when (call.method) { when (call.method) {
"packageInfo" -> result.success(packageInfoMap()) "packageInfo" -> result.success(packageInfoMap())
@@ -73,18 +71,15 @@ class MainActivity : FlutterActivity() {
android.content.pm.PackageManager.PackageInfoFlags.of(0), android.content.pm.PackageManager.PackageInfoFlags.of(0),
) )
} else { } else {
@Suppress("DEPRECATION") @Suppress("DEPRECATION") packageManager.getPackageInfo(packageName, 0)
packageManager.getPackageInfo(packageName, 0)
} }
val appName = val appName = applicationInfo.loadLabel(packageManager)?.toString().orEmpty()
applicationInfo.loadLabel(packageManager)?.toString().orEmpty()
val versionCode = val versionCode =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
packageInfo.longVersionCode.toString() packageInfo.longVersionCode.toString()
} else { } else {
@Suppress("DEPRECATION") @Suppress("DEPRECATION") packageInfo.versionCode.toString()
packageInfo.versionCode.toString()
} }
return mapOf( return mapOf(

View File

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

View File

@@ -1,9 +1,8 @@
package com.gdfw.fxjk.recording package com.qxy.dronex.recording
import android.app.NotificationManager import android.app.NotificationManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build
import android.provider.Settings import android.provider.Settings
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
@@ -16,7 +15,8 @@ object DoNotDisturbHelper {
} }
fun openAccessSettings(context: Context) { fun openAccessSettings(context: Context) {
val intent = Intent(Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS).apply { val intent =
Intent(Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
} }
context.startActivity(intent) context.startActivity(intent)

View File

@@ -1,4 +1,4 @@
package com.gdfw.fxjk.recording package com.qxy.dronex.recording
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log
@@ -167,7 +167,8 @@ class RecordingCameraController(
updateStatus( updateStatus(
RecordingStatus( RecordingStatus(
RecordingState.ERROR, RecordingState.ERROR,
message = event.cause?.message ?: "Recording failed", message = event.cause?.message
?: "Recording failed",
), ),
) )
} else { } else {
@@ -176,7 +177,9 @@ class RecordingCameraController(
RecordingStatus( RecordingStatus(
RecordingState.PREVIEWING, RecordingState.PREVIEWING,
outputPath = latestOutputPath, outputPath = latestOutputPath,
elapsedMillis = System.currentTimeMillis() - recordingStartedAt, elapsedMillis =
System.currentTimeMillis() -
recordingStartedAt,
), ),
) )
} }

View File

@@ -1,22 +1,21 @@
package com.gdfw.fxjk.recording package com.qxy.dronex.recording
import android.app.Notification import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.app.Service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.pm.ServiceInfo import android.content.pm.ServiceInfo
import android.os.Build import android.os.Build
import androidx.core.content.ContextCompat
import android.os.IBinder import android.os.IBinder
import android.os.PowerManager import android.os.PowerManager
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleService import androidx.lifecycle.LifecycleService
import com.gdfw.fxjk.AppConstants import com.qxy.dronex.AppConstants
import com.gdfw.fxjk.MainActivity import com.qxy.dronex.MainActivity
class RecordingForegroundService : LifecycleService() { class RecordingForegroundService : LifecycleService() {
private var wakeLock: PowerManager.WakeLock? = null private var wakeLock: PowerManager.WakeLock? = null
@@ -94,7 +93,8 @@ class RecordingForegroundService : LifecycleService() {
CHANNEL_ID, CHANNEL_ID,
"录制服务", "录制服务",
NotificationManager.IMPORTANCE_LOW, NotificationManager.IMPORTANCE_LOW,
).apply { )
.apply {
description = "保持相机录制在后台与息屏时继续运行" description = "保持相机录制在后台与息屏时继续运行"
setShowBadge(false) setShowBadge(false)
} }
@@ -146,11 +146,9 @@ class RecordingForegroundService : LifecycleService() {
const val NOTIFICATION_ID = 1001 const val NOTIFICATION_ID = 1001
private const val WAKE_LOCK_TAG = "record_tool:recording_wake_lock" private const val WAKE_LOCK_TAG = "record_tool:recording_wake_lock"
@Volatile @Volatile var isRunning: Boolean = false
var isRunning: Boolean = false
@Volatile @Volatile var instance: RecordingForegroundService? = null
var instance: RecordingForegroundService? = null
fun start(context: Context) { fun start(context: Context) {
val intent = val intent =

View File

@@ -1,4 +1,4 @@
package com.gdfw.fxjk.recording package com.qxy.dronex.recording
import android.content.ContentValues import android.content.ContentValues
import android.content.Context import android.content.Context
@@ -44,8 +44,7 @@ object RecordingOutputFactory {
"$trimmed.mp4" "$trimmed.mp4"
} }
} }
val timestamp = val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
return "REC_$timestamp.mp4" return "REC_$timestamp.mp4"
} }
} }

View File

@@ -1,14 +1,12 @@
package com.gdfw.fxjk.recording package com.qxy.dronex.recording
import android.app.Activity
import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.WindowInsetsControllerCompat
import com.gdfw.fxjk.AppConstants import com.qxy.dronex.AppConstants
import com.gdfw.fxjk.MainActivity import com.qxy.dronex.MainActivity
import io.flutter.plugin.common.BinaryMessenger import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
@@ -18,10 +16,8 @@ class RecordingPlatformHandler(
private val activity: MainActivity, private val activity: MainActivity,
messenger: BinaryMessenger, messenger: BinaryMessenger,
) : MethodChannel.MethodCallHandler, EventChannel.StreamHandler { ) : MethodChannel.MethodCallHandler, EventChannel.StreamHandler {
private val methodChannel = private val methodChannel = MethodChannel(messenger, AppConstants.RECORDING_METHOD_CHANNEL)
MethodChannel(messenger, AppConstants.RECORDING_METHOD_CHANNEL) private val eventChannel = EventChannel(messenger, AppConstants.RECORDING_EVENT_CHANNEL)
private val eventChannel =
EventChannel(messenger, AppConstants.RECORDING_EVENT_CHANNEL)
private val mainHandler = Handler(Looper.getMainLooper()) private val mainHandler = Handler(Looper.getMainLooper())
private var eventSink: EventChannel.EventSink? = null private var eventSink: EventChannel.EventSink? = null
@@ -33,9 +29,7 @@ class RecordingPlatformHandler(
methodChannel.setMethodCallHandler(this) methodChannel.setMethodCallHandler(this)
eventChannel.setStreamHandler(this) eventChannel.setStreamHandler(this)
controller.statusListener = { status -> controller.statusListener = { status ->
mainHandler.post { mainHandler.post { eventSink?.success(status.toMap()) }
eventSink?.success(status.toMap())
}
} }
} }
@@ -60,14 +54,12 @@ class RecordingPlatformHandler(
controller.unbind() controller.unbind()
result.success(null) result.success(null)
} }
"hasNotificationPolicyAccess" -> "hasNotificationPolicyAccess" -> result.success(DoNotDisturbHelper.hasAccess(activity))
result.success(DoNotDisturbHelper.hasAccess(activity))
"openNotificationPolicySettings" -> { "openNotificationPolicySettings" -> {
DoNotDisturbHelper.openAccessSettings(activity) DoNotDisturbHelper.openAccessSettings(activity)
result.success(null) result.success(null)
} }
"enableDoNotDisturb" -> "enableDoNotDisturb" -> result.success(DoNotDisturbHelper.enable(activity))
result.success(DoNotDisturbHelper.enable(activity))
"disableDoNotDisturb" -> { "disableDoNotDisturb" -> {
DoNotDisturbHelper.disable(activity) DoNotDisturbHelper.disable(activity)
result.success(null) result.success(null)
@@ -84,8 +76,7 @@ class RecordingPlatformHandler(
result.success(null) result.success(null)
} }
"getStatus" -> result.success(controller.status.toMap()) "getStatus" -> result.success(controller.status.toMap())
"isForegroundServiceRunning" -> "isForegroundServiceRunning" -> result.success(RecordingForegroundService.isRunning)
result.success(RecordingForegroundService.isRunning)
else -> result.notImplemented() else -> result.notImplemented()
} }
} }
@@ -147,8 +138,7 @@ class RecordingPlatformHandler(
} }
fun rebindAndCapture() { fun rebindAndCapture() {
val lifecycleOwner = val lifecycleOwner = RecordingForegroundService.instance ?: activity
RecordingForegroundService.instance ?: activity
controller.rebindForRecording(lifecycleOwner, previewView) { ready -> controller.rebindForRecording(lifecycleOwner, previewView) { ready ->
if (ready) { if (ready) {
beginCapture() beginCapture()
@@ -172,17 +162,15 @@ class RecordingPlatformHandler(
RecordingSession.stopForeground(activity) RecordingSession.stopForeground(activity)
DoNotDisturbHelper.disable(activity) DoNotDisturbHelper.disable(activity)
mainHandler.post { mainHandler.post {
val gallerySaved = val gallerySaved = path != null && controller.status.state != RecordingState.ERROR
path != null && val payload =
controller.status.state != RecordingState.ERROR mutableMapOf<String, Any?>(
val payload = mutableMapOf<String, Any?>(
"outputPath" to path, "outputPath" to path,
"status" to controller.status.toMap(), "status" to controller.status.toMap(),
"gallerySaved" to gallerySaved, "gallerySaved" to gallerySaved,
) )
if (!gallerySaved) { if (!gallerySaved) {
payload["galleryErrorMessage"] = payload["galleryErrorMessage"] = controller.status.message ?: "保存到相册失败"
controller.status.message ?: "保存到相册失败"
} }
result.success(payload) result.success(payload)
} }
@@ -209,16 +197,19 @@ class RecordingPlatformHandler(
override fun run() { override fun run() {
if (controller.status.state == RecordingState.RECORDING) { if (controller.status.state == RecordingState.RECORDING) {
eventSink?.success( eventSink?.success(
controller.status.copy( controller
elapsedMillis = controller.elapsedMillis(), .status
).toMap(), .copy(
elapsedMillis =
controller.elapsedMillis(),
)
.toMap(),
) )
mainHandler.postDelayed(this, 1000L) mainHandler.postDelayed(this, 1000L)
} }
} }
}.also {
mainHandler.post(it)
} }
.also { mainHandler.post(it) }
} }
private fun stopElapsedTicker() { private fun stopElapsedTicker() {

View File

@@ -1,9 +1,9 @@
package com.gdfw.fxjk.recording package com.qxy.dronex.recording
import android.content.Context import android.content.Context
import android.view.View import android.view.View
import androidx.camera.view.PreviewView import androidx.camera.view.PreviewView
import com.gdfw.fxjk.MainActivity import com.qxy.dronex.MainActivity
import io.flutter.plugin.common.StandardMessageCodec import io.flutter.plugin.common.StandardMessageCodec
import io.flutter.plugin.platform.PlatformView import io.flutter.plugin.platform.PlatformView
import io.flutter.plugin.platform.PlatformViewFactory import io.flutter.plugin.platform.PlatformViewFactory

View File

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

View File

@@ -1,4 +1,4 @@
package com.gdfw.fxjk.recording package com.qxy.dronex.recording
enum class RecordingState { enum class RecordingState {
IDLE, IDLE,

View File

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

View File

@@ -142,7 +142,8 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
func initializePreview(result: @escaping FlutterResult) { func initializePreview(result: @escaping FlutterResult) {
guard let previewView else { guard let previewView else {
result(FlutterError(code: "NO_PREVIEW", message: "Camera preview is not attached", details: nil)) result(
FlutterError(code: "NO_PREVIEW", message: "Camera preview is not attached", details: nil))
return return
} }
@@ -176,7 +177,8 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
func startRecording(withAudio: Bool, displayName: String?, result: @escaping FlutterResult) { func startRecording(withAudio: Bool, displayName: String?, result: @escaping FlutterResult) {
guard previewView != nil else { guard previewView != nil else {
result(FlutterError(code: "NO_PREVIEW", message: "Camera preview is not attached", details: nil)) result(
FlutterError(code: "NO_PREVIEW", message: "Camera preview is not attached", details: nil))
return return
} }
@@ -306,7 +308,9 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
if let error { if let error {
latestGallerySaved = false latestGallerySaved = false
latestGalleryErrorMessage = error.localizedDescription latestGalleryErrorMessage = error.localizedDescription
updateStatus(RecordingStatus(state: .error, outputPath: latestOutputPath, message: error.localizedDescription)) updateStatus(
RecordingStatus(
state: .error, outputPath: latestOutputPath, message: error.localizedDescription))
finishStopRecording(stopResult: stopResult) finishStopRecording(stopResult: stopResult)
return return
} }
@@ -411,10 +415,14 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
return return
} }
guard let videoDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) guard
let videoDevice = AVCaptureDevice.default(
.builtInWideAngleCamera, for: .video, position: .back)
?? AVCaptureDevice.default(for: .video) ?? AVCaptureDevice.default(for: .video)
else { else {
throw NSError(domain: "RecordingCamera", code: 1, userInfo: [NSLocalizedDescriptionKey: "No camera device available"]) throw NSError(
domain: "RecordingCamera", code: 1,
userInfo: [NSLocalizedDescriptionKey: "No camera device available"])
} }
let nextVideoInput = try AVCaptureDeviceInput(device: videoDevice) let nextVideoInput = try AVCaptureDeviceInput(device: videoDevice)
@@ -424,14 +432,18 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
guard session.canAddInput(nextVideoInput) else { guard session.canAddInput(nextVideoInput) else {
session.commitConfiguration() session.commitConfiguration()
throw NSError(domain: "RecordingCamera", code: 2, userInfo: [NSLocalizedDescriptionKey: "Cannot add camera input"]) throw NSError(
domain: "RecordingCamera", code: 2,
userInfo: [NSLocalizedDescriptionKey: "Cannot add camera input"])
} }
session.addInput(nextVideoInput) session.addInput(nextVideoInput)
videoInput = nextVideoInput videoInput = nextVideoInput
guard session.canAddOutput(movieOutput) else { guard session.canAddOutput(movieOutput) else {
session.commitConfiguration() session.commitConfiguration()
throw NSError(domain: "RecordingCamera", code: 3, userInfo: [NSLocalizedDescriptionKey: "Cannot add movie output"]) throw NSError(
domain: "RecordingCamera", code: 3,
userInfo: [NSLocalizedDescriptionKey: "Cannot add movie output"])
} }
session.addOutput(movieOutput) session.addOutput(movieOutput)
session.commitConfiguration() session.commitConfiguration()
@@ -516,7 +528,7 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
} }
private enum RecordingChannelNames { private enum RecordingChannelNames {
static let packageName = "com.gdfw.fxjk" static let packageName = "com.qxy.dronex"
static let method = "\(packageName)/recording" static let method = "\(packageName)/recording"
static let events = "\(packageName)/recording_events" static let events = "\(packageName)/recording_events"
} }
@@ -584,7 +596,9 @@ final class RecordingPlugin: NSObject, FlutterPlugin, FlutterStreamHandler {
} }
} }
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink)
-> FlutterError?
{
eventSink = events eventSink = events
events(controller.currentStatusMap()) events(controller.currentStatusMap())
return nil return nil

View File

@@ -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,
);
}
} }
} }

View File

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

View File

@@ -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,
}); });

View File

@@ -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';
} }

View File

@@ -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: () {}),
],
], ],
), ),
); );

View File

@@ -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();

View File

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

View File

@@ -0,0 +1,120 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
/// 录制结束并保存到相册后的后续操作弹窗。
Future<void> showRecordingSavedDialog(
BuildContext context, {
required String sessionTitle,
required VoidCallback onContinueRound,
required VoidCallback onRecordNewRound,
}) {
return showDialog<void>(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
return _RecordingSavedDialog(
sessionTitle: sessionTitle,
onContinueRound: () {
Navigator.of(dialogContext).pop();
onContinueRound();
},
onRecordNewRound: () {
Navigator.of(dialogContext).pop();
onRecordNewRound();
},
);
},
);
}
class _RecordingSavedDialog extends StatelessWidget {
const _RecordingSavedDialog({
required this.sessionTitle,
required this.onContinueRound,
required this.onRecordNewRound,
});
final String sessionTitle;
final VoidCallback onContinueRound;
final VoidCallback onRecordNewRound;
@override
Widget build(BuildContext context) {
return Dialog(
backgroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4.r),
side: const BorderSide(color: Colors.black, width: 1),
),
insetPadding: EdgeInsets.symmetric(horizontal: 32.w),
child: Padding(
padding: EdgeInsets.fromLTRB(20.w, 20.h, 20.w, 16.h),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
sessionTitle,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
textAlign: TextAlign.center,
),
SizedBox(height: 16.h),
Text(
'本轮比赛视频已保存到相册',
style: TextStyle(fontSize: 14.sp, color: Colors.black87),
textAlign: TextAlign.center,
),
SizedBox(height: 8.h),
// Text(
// '请选择后续录制信息',
// style: TextStyle(fontSize: 14.sp, color: Colors.black87),
// textAlign: TextAlign.center,
// ),
SizedBox(height: 20.h),
Row(
children: [
Expanded(
child: _DialogActionButton(
label: '继续本轮',
onPressed: onContinueRound,
),
),
SizedBox(width: 12.w),
Expanded(
child: _DialogActionButton(
label: '录制新轮',
onPressed: onRecordNewRound,
),
),
],
),
],
),
),
);
}
}
class _DialogActionButton extends StatelessWidget {
const _DialogActionButton({required this.label, required this.onPressed});
final String label;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return TextButton(
onPressed: onPressed,
style: TextButton.styleFrom(
backgroundColor: const Color(0xFFE8E8E8),
foregroundColor: Colors.black87,
padding: EdgeInsets.symmetric(vertical: 10.h),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6.r)),
),
child: Text(label, style: TextStyle(fontSize: 14.sp)),
);
}
}