Compare commits
3 Commits
fb61e28e2f
...
66435302b3
| Author | SHA1 | Date | |
|---|---|---|---|
| 66435302b3 | |||
| 250f21a2b8 | |||
| 8f9f3a9779 |
1
.gitignore
vendored
@@ -11,6 +11,7 @@
|
||||
.svn/
|
||||
.swiftpm/
|
||||
migrate_working_dir/
|
||||
.vscode
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
|
||||
34
README.en.md
@@ -13,22 +13,22 @@ A production-ready Flutter quick-start template extracted from real-world projec
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Category | Package | Purpose |
|
||||
|---|---|---|
|
||||
| State Management | flutter_riverpod | Compile-safe, testable state management |
|
||||
| Networking | dio | HTTP client with interceptor chain |
|
||||
| Local Cache | shared_preferences | Key-value persistence |
|
||||
| Network Monitor | connectivity_plus | Real-time connectivity tracking |
|
||||
| Permissions | permission_handler | Runtime permission requests |
|
||||
| Screen Adaptation | flutter_screenutil | Design-dimension-based layout |
|
||||
| Image Loading | cached_network_image | Network image caching |
|
||||
| SVG | flutter_svg | SVG rendering |
|
||||
| Pull to Refresh | pull_to_refresh | Refresh and load-more |
|
||||
| Loading HUD | flutter_easyloading | Toast and loading indicator |
|
||||
| Device Info | device_info_plus | Device metadata |
|
||||
| Package Info | package_info_plus | App version info |
|
||||
| URL Launcher | url_launcher | Open external URLs |
|
||||
| Linting | flutter_lints | Recommended Dart lint rules |
|
||||
| Category | Package | Purpose |
|
||||
| ----------------- | -------------------- | --------------------------------------- |
|
||||
| State Management | flutter_riverpod | Compile-safe, testable state management |
|
||||
| Networking | dio | HTTP client with interceptor chain |
|
||||
| Local Cache | shared_preferences | Key-value persistence |
|
||||
| Network Monitor | connectivity_plus | Real-time connectivity tracking |
|
||||
| Permissions | permission_handler | Runtime permission requests |
|
||||
| Screen Adaptation | flutter_screenutil | Design-dimension-based layout |
|
||||
| Image Loading | cached_network_image | Network image caching |
|
||||
| SVG | flutter_svg | SVG rendering |
|
||||
| Pull to Refresh | pull_to_refresh | Refresh and load-more |
|
||||
| Loading HUD | flutter_easyloading | Toast and loading indicator |
|
||||
| Device Info | device_info_plus | Device metadata |
|
||||
| Package Info | package_info_plus | App version info |
|
||||
| URL Launcher | url_launcher | Open external URLs |
|
||||
| Linting | flutter_lints | Recommended Dart lint rules |
|
||||
|
||||
## Directory Structure
|
||||
|
||||
@@ -72,7 +72,7 @@ lib/
|
||||
## Getting Started
|
||||
|
||||
```bash
|
||||
cd flutter-template
|
||||
cd record-tool
|
||||
flutter pub get
|
||||
flutter analyze
|
||||
flutter test
|
||||
|
||||
34
README.md
@@ -13,22 +13,22 @@
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 依赖 | 用途 |
|
||||
|---|---|---|
|
||||
| 状态管理 | flutter_riverpod | 编译安全、可测试的状态管理 |
|
||||
| 网络请求 | dio | HTTP 客户端,支持拦截器链 |
|
||||
| 本地缓存 | shared_preferences | KV 持久化存储 |
|
||||
| 网络监听 | connectivity_plus | 实时网络状态监测 |
|
||||
| 权限申请 | permission_handler | 运行时权限请求 |
|
||||
| 屏幕适配 | flutter_screenutil | 设计稿尺寸适配 |
|
||||
| 图片加载 | cached_network_image | 网络图片缓存 |
|
||||
| SVG | flutter_svg | SVG 渲染 |
|
||||
| 下拉刷新 | pull_to_refresh | 下拉刷新 / 上拉加载 |
|
||||
| 加载提示 | flutter_easyloading | Toast 和 loading |
|
||||
| 设备信息 | device_info_plus | 设备元数据 |
|
||||
| 应用信息 | package_info_plus | 版本号等应用信息 |
|
||||
| 链接跳转 | url_launcher | 外部 URL 打开 |
|
||||
| 代码规范 | flutter_lints | Dart 推荐 lint 规则 |
|
||||
| 类别 | 依赖 | 用途 |
|
||||
| -------- | -------------------- | -------------------------- |
|
||||
| 状态管理 | flutter_riverpod | 编译安全、可测试的状态管理 |
|
||||
| 网络请求 | dio | HTTP 客户端,支持拦截器链 |
|
||||
| 本地缓存 | shared_preferences | KV 持久化存储 |
|
||||
| 网络监听 | connectivity_plus | 实时网络状态监测 |
|
||||
| 权限申请 | permission_handler | 运行时权限请求 |
|
||||
| 屏幕适配 | flutter_screenutil | 设计稿尺寸适配 |
|
||||
| 图片加载 | cached_network_image | 网络图片缓存 |
|
||||
| SVG | flutter_svg | SVG 渲染 |
|
||||
| 下拉刷新 | pull_to_refresh | 下拉刷新 / 上拉加载 |
|
||||
| 加载提示 | flutter_easyloading | Toast 和 loading |
|
||||
| 设备信息 | device_info_plus | 设备元数据 |
|
||||
| 应用信息 | package_info_plus | 版本号等应用信息 |
|
||||
| 链接跳转 | url_launcher | 外部 URL 打开 |
|
||||
| 代码规范 | flutter_lints | Dart 推荐 lint 规则 |
|
||||
|
||||
## 目录结构
|
||||
|
||||
@@ -71,7 +71,7 @@ lib/
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
cd flutter-template
|
||||
cd record-tool
|
||||
flutter pub get
|
||||
flutter analyze
|
||||
flutter test
|
||||
|
||||
@@ -5,8 +5,10 @@ plugins {
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
val appPackageName = "com.gdfw.fxjk"
|
||||
|
||||
android {
|
||||
namespace = "com.example.flutter_template"
|
||||
namespace = appPackageName
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
@@ -20,8 +22,7 @@ android {
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "com.example.flutter_template"
|
||||
applicationId = appPackageName
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
|
||||
@@ -1,4 +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">
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
@@ -8,6 +9,10 @@
|
||||
<uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera"
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.gdfw.fxjk
|
||||
|
||||
object AppConstants {
|
||||
const val PACKAGE_NAME = "com.gdfw.fxjk"
|
||||
const val RECORDING_METHOD_CHANNEL = "$PACKAGE_NAME/recording"
|
||||
const val RECORDING_EVENT_CHANNEL = "$PACKAGE_NAME/recording_events"
|
||||
const val RECORDING_ACTION_START = "$PACKAGE_NAME.recording.START"
|
||||
const val RECORDING_ACTION_STOP = "$PACKAGE_NAME.recording.STOP"
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
package com.example.flutter_template
|
||||
package com.gdfw.fxjk
|
||||
|
||||
import androidx.camera.view.PreviewView
|
||||
import com.example.flutter_template.recording.RecordingPlatformHandler
|
||||
import com.example.flutter_template.recording.RecordingPreviewFactory
|
||||
import com.gdfw.fxjk.recording.RecordingPlatformHandler
|
||||
import com.gdfw.fxjk.recording.RecordingPreviewFactory
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.example.flutter_template.recording
|
||||
package com.gdfw.fxjk.recording
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.example.flutter_template.recording
|
||||
package com.gdfw.fxjk.recording
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
@@ -1,11 +1,10 @@
|
||||
package com.example.flutter_template.recording
|
||||
package com.gdfw.fxjk.recording
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.video.FileOutputOptions
|
||||
import androidx.camera.video.Quality
|
||||
import androidx.camera.video.QualitySelector
|
||||
import androidx.camera.video.Recorder
|
||||
@@ -15,10 +14,6 @@ import androidx.camera.video.VideoRecordEvent
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.Executor
|
||||
|
||||
class RecordingCameraController(
|
||||
@@ -39,6 +34,7 @@ class RecordingCameraController(
|
||||
|
||||
private var recordingStartedAt: Long = 0L
|
||||
private var latestOutputPath: String? = null
|
||||
private var pendingStopCallback: ((String?) -> Unit)? = null
|
||||
|
||||
fun bindPreview(
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
@@ -118,6 +114,7 @@ class RecordingCameraController(
|
||||
|
||||
fun startRecording(
|
||||
withAudio: Boolean,
|
||||
displayName: String?,
|
||||
onStarted: (Boolean, String?) -> Unit,
|
||||
) {
|
||||
val capture = videoCapture
|
||||
@@ -131,9 +128,12 @@ class RecordingCameraController(
|
||||
return
|
||||
}
|
||||
|
||||
val outputFile = createOutputFile()
|
||||
latestOutputPath = outputFile.absolutePath
|
||||
val outputOptions = FileOutputOptions.Builder(outputFile).build()
|
||||
val outputOptions =
|
||||
RecordingOutputFactory.buildMediaStoreOutputOptions(
|
||||
appContext,
|
||||
displayName,
|
||||
)
|
||||
latestOutputPath = null
|
||||
|
||||
val pending =
|
||||
capture.output.prepareRecording(appContext, outputOptions).apply {
|
||||
@@ -171,6 +171,7 @@ class RecordingCameraController(
|
||||
),
|
||||
)
|
||||
} else {
|
||||
latestOutputPath = event.outputResults.outputUri.toString()
|
||||
updateStatus(
|
||||
RecordingStatus(
|
||||
RecordingState.PREVIEWING,
|
||||
@@ -179,11 +180,14 @@ class RecordingCameraController(
|
||||
),
|
||||
)
|
||||
}
|
||||
val stopCallback = pendingStopCallback
|
||||
pendingStopCallback = null
|
||||
stopCallback?.invoke(latestOutputPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onStarted(true, latestOutputPath)
|
||||
onStarted(true, latestOutputPath ?: "recording")
|
||||
}
|
||||
|
||||
fun stopRecording(onStopped: (String?) -> Unit) {
|
||||
@@ -193,6 +197,7 @@ class RecordingCameraController(
|
||||
return
|
||||
}
|
||||
|
||||
pendingStopCallback = onStopped
|
||||
updateStatus(
|
||||
RecordingStatus(
|
||||
RecordingState.STOPPING,
|
||||
@@ -202,7 +207,6 @@ class RecordingCameraController(
|
||||
|
||||
recording.stop()
|
||||
activeRecording = null
|
||||
onStopped(latestOutputPath)
|
||||
}
|
||||
|
||||
fun unbind() {
|
||||
@@ -221,16 +225,6 @@ class RecordingCameraController(
|
||||
return System.currentTimeMillis() - recordingStartedAt
|
||||
}
|
||||
|
||||
private fun createOutputFile(): File {
|
||||
val moviesDir = File(appContext.getExternalFilesDir(null), "recordings")
|
||||
if (!moviesDir.exists()) {
|
||||
moviesDir.mkdirs()
|
||||
}
|
||||
val timestamp =
|
||||
SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
||||
return File(moviesDir, "REC_$timestamp.mp4")
|
||||
}
|
||||
|
||||
private fun updateStatus(next: RecordingStatus) {
|
||||
status = next
|
||||
statusListener?.invoke(next)
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.example.flutter_template.recording
|
||||
package com.gdfw.fxjk.recording
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
@@ -15,7 +15,8 @@ import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.lifecycle.LifecycleService
|
||||
import com.example.flutter_template.MainActivity
|
||||
import com.gdfw.fxjk.AppConstants
|
||||
import com.gdfw.fxjk.MainActivity
|
||||
|
||||
class RecordingForegroundService : LifecycleService() {
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
@@ -29,7 +30,7 @@ class RecordingForegroundService : LifecycleService() {
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
super.onStartCommand(intent, flags, startId)
|
||||
when (intent?.action) {
|
||||
ACTION_START -> {
|
||||
AppConstants.RECORDING_ACTION_START -> {
|
||||
acquireWakeLock()
|
||||
val notification = buildNotification("正在录制")
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
@@ -43,7 +44,7 @@ class RecordingForegroundService : LifecycleService() {
|
||||
}
|
||||
isRunning = true
|
||||
}
|
||||
ACTION_STOP -> {
|
||||
AppConstants.RECORDING_ACTION_STOP -> {
|
||||
releaseWakeLock()
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
isRunning = false
|
||||
@@ -143,8 +144,6 @@ class RecordingForegroundService : LifecycleService() {
|
||||
companion object {
|
||||
const val CHANNEL_ID = "recording_foreground"
|
||||
const val NOTIFICATION_ID = 1001
|
||||
const val ACTION_START = "com.example.flutter_template.recording.START"
|
||||
const val ACTION_STOP = "com.example.flutter_template.recording.STOP"
|
||||
private const val WAKE_LOCK_TAG = "record_tool:recording_wake_lock"
|
||||
|
||||
@Volatile
|
||||
@@ -156,7 +155,7 @@ class RecordingForegroundService : LifecycleService() {
|
||||
fun start(context: Context) {
|
||||
val intent =
|
||||
Intent(context, RecordingForegroundService::class.java).apply {
|
||||
action = ACTION_START
|
||||
action = AppConstants.RECORDING_ACTION_START
|
||||
}
|
||||
ContextCompatStart.startForegroundService(context, intent)
|
||||
}
|
||||
@@ -164,7 +163,7 @@ class RecordingForegroundService : LifecycleService() {
|
||||
fun stop(context: Context) {
|
||||
val intent =
|
||||
Intent(context, RecordingForegroundService::class.java).apply {
|
||||
action = ACTION_STOP
|
||||
action = AppConstants.RECORDING_ACTION_STOP
|
||||
}
|
||||
context.startService(intent)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.gdfw.fxjk.recording
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import androidx.camera.video.MediaStoreOutputOptions
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
object RecordingOutputFactory {
|
||||
private const val RELATIVE_PATH = "Movies/飞行极控"
|
||||
private const val MIME_TYPE = "video/mp4"
|
||||
|
||||
fun buildMediaStoreOutputOptions(
|
||||
context: Context,
|
||||
displayName: String?,
|
||||
): MediaStoreOutputOptions {
|
||||
val fileName = resolveFileName(displayName)
|
||||
val contentValues =
|
||||
ContentValues().apply {
|
||||
put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
|
||||
put(MediaStore.MediaColumns.MIME_TYPE, MIME_TYPE)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
put(MediaStore.Video.Media.RELATIVE_PATH, RELATIVE_PATH)
|
||||
}
|
||||
}
|
||||
|
||||
return MediaStoreOutputOptions.Builder(
|
||||
context.contentResolver,
|
||||
MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
|
||||
)
|
||||
.setContentValues(contentValues)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun resolveFileName(displayName: String?): String {
|
||||
val trimmed = displayName?.trim().orEmpty()
|
||||
if (trimmed.isNotEmpty()) {
|
||||
return if (trimmed.lowercase(Locale.US).endsWith(".mp4")) {
|
||||
trimmed
|
||||
} else {
|
||||
"$trimmed.mp4"
|
||||
}
|
||||
}
|
||||
val timestamp =
|
||||
SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
||||
return "REC_$timestamp.mp4"
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.example.flutter_template.recording
|
||||
package com.gdfw.fxjk.recording
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
@@ -7,7 +7,8 @@ import android.os.Looper
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import com.example.flutter_template.MainActivity
|
||||
import com.gdfw.fxjk.AppConstants
|
||||
import com.gdfw.fxjk.MainActivity
|
||||
import io.flutter.plugin.common.BinaryMessenger
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
@@ -18,9 +19,9 @@ class RecordingPlatformHandler(
|
||||
messenger: BinaryMessenger,
|
||||
) : MethodChannel.MethodCallHandler, EventChannel.StreamHandler {
|
||||
private val methodChannel =
|
||||
MethodChannel(messenger, "com.example.flutter_template/recording")
|
||||
MethodChannel(messenger, AppConstants.RECORDING_METHOD_CHANNEL)
|
||||
private val eventChannel =
|
||||
EventChannel(messenger, "com.example.flutter_template/recording_events")
|
||||
EventChannel(messenger, AppConstants.RECORDING_EVENT_CHANNEL)
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private var eventSink: EventChannel.EventSink? = null
|
||||
@@ -51,7 +52,8 @@ class RecordingPlatformHandler(
|
||||
"startRecording" -> {
|
||||
val withAudio = call.argument<Boolean>("withAudio") ?: true
|
||||
val enableDnd = call.argument<Boolean>("enableDoNotDisturb") ?: true
|
||||
startRecording(withAudio, enableDnd, result)
|
||||
val displayName = call.argument<String>("displayName")
|
||||
startRecording(withAudio, enableDnd, displayName, result)
|
||||
}
|
||||
"stopRecording" -> stopRecording(result)
|
||||
"disposePreview" -> {
|
||||
@@ -109,6 +111,7 @@ class RecordingPlatformHandler(
|
||||
private fun startRecording(
|
||||
withAudio: Boolean,
|
||||
enableDnd: Boolean,
|
||||
displayName: String?,
|
||||
result: MethodChannel.Result,
|
||||
) {
|
||||
val previewView = activity.recordingPreviewView
|
||||
@@ -124,7 +127,7 @@ class RecordingPlatformHandler(
|
||||
DoNotDisturbHelper.enable(activity)
|
||||
}
|
||||
|
||||
controller.startRecording(withAudio) { started, message ->
|
||||
controller.startRecording(withAudio, displayName) { started, message ->
|
||||
mainHandler.post {
|
||||
if (started) {
|
||||
startElapsedTicker()
|
||||
@@ -169,12 +172,19 @@ class RecordingPlatformHandler(
|
||||
RecordingSession.stopForeground(activity)
|
||||
DoNotDisturbHelper.disable(activity)
|
||||
mainHandler.post {
|
||||
result.success(
|
||||
mapOf(
|
||||
"outputPath" to path,
|
||||
"status" to controller.status.toMap(),
|
||||
),
|
||||
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 ?: "保存到相册失败"
|
||||
}
|
||||
result.success(payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
package com.example.flutter_template.recording
|
||||
package com.gdfw.fxjk.recording
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import androidx.camera.view.PreviewView
|
||||
import com.example.flutter_template.MainActivity
|
||||
import com.gdfw.fxjk.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.example.flutter_template.recording
|
||||
package com.gdfw.fxjk.recording
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.LifecycleService
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.example.flutter_template.recording
|
||||
package com.gdfw.fxjk.recording
|
||||
|
||||
enum class RecordingState {
|
||||
IDLE,
|
||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 7.1 KiB |
663
android/build/reports/problems/problems-report.html
Normal file
@@ -1,4 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=1G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
# This builtInKotlin flag was added automatically by Flutter migrator
|
||||
|
||||
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
|
||||
|
||||
@@ -19,8 +19,8 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.9.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
|
||||
id("com.android.application") version "8.11.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
|
||||
@@ -20,7 +20,5 @@
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>13.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
10
ios/Podfile
@@ -39,5 +39,15 @@ end
|
||||
post_install do |installer|
|
||||
installer.pods_project.targets.each do |target|
|
||||
flutter_additional_ios_build_settings(target)
|
||||
|
||||
target.build_configurations.each do |config|
|
||||
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [
|
||||
'$(inherited)',
|
||||
'PERMISSION_CAMERA=1',
|
||||
'PERMISSION_MICROPHONE=1',
|
||||
'PERMISSION_PHOTOS=1',
|
||||
'PERMISSION_PHOTOS_ADD_ONLY=1',
|
||||
]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,67 +1,22 @@
|
||||
PODS:
|
||||
- connectivity_plus (0.0.1):
|
||||
- Flutter
|
||||
- device_info_plus (0.0.1):
|
||||
- Flutter
|
||||
- Flutter (1.0.0)
|
||||
- package_info_plus (0.4.5):
|
||||
- Flutter
|
||||
- path_provider_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- permission_handler_apple (9.3.0):
|
||||
- Flutter
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- sqflite_darwin (0.0.4):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- url_launcher_ios (0.0.1):
|
||||
- Flutter
|
||||
|
||||
DEPENDENCIES:
|
||||
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
||||
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
connectivity_plus:
|
||||
:path: ".symlinks/plugins/connectivity_plus/ios"
|
||||
device_info_plus:
|
||||
:path: ".symlinks/plugins/device_info_plus/ios"
|
||||
Flutter:
|
||||
:path: Flutter
|
||||
package_info_plus:
|
||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||
path_provider_foundation:
|
||||
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
||||
permission_handler_apple:
|
||||
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
||||
shared_preferences_foundation:
|
||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||
sqflite_darwin:
|
||||
:path: ".symlinks/plugins/sqflite_darwin/darwin"
|
||||
url_launcher_ios:
|
||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
||||
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
||||
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
||||
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
||||
|
||||
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
|
||||
PODFILE CHECKSUM: 6f58d96c1fb09d1d57dbcbf38a5d856d7302054a
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||
64BC3B3C75FABAA128C4FE7A /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D84231833EC084C45F76D9A0 /* Pods_RunnerTests.framework */; };
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
||||
8D5D9D9E2C69000100F10001 /* RecordingPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D5D9D9D2C69000100F10001 /* RecordingPlugin.swift */; };
|
||||
87DA76CA5231F693C9392B80 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B6AA1C86CE710449CC3E8FCD /* Pods_Runner.framework */; };
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
@@ -51,6 +53,8 @@
|
||||
69561E7442E8CC939DB2B482 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
|
||||
8D5D9D9D2C69000100F10001 /* RecordingPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingPlugin.swift; sourceTree = "<group>"; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||
95C6AC1536EA3DBA2D369C55 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||
@@ -80,6 +84,7 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
|
||||
87DA76CA5231F693C9392B80 /* Pods_Runner.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -121,6 +126,7 @@
|
||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
||||
@@ -160,6 +166,7 @@
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||
8D5D9D9D2C69000100F10001 /* RecordingPlugin.swift */,
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||
);
|
||||
path = Runner;
|
||||
@@ -198,7 +205,6 @@
|
||||
97C146EC1CF9000F007C117D /* Resources */,
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||
CD126DA202245CE8BB749787 /* [CP] Embed Pods Frameworks */,
|
||||
62D3774184B6C76B8E34C8C2 /* [CP] Copy Pods Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
@@ -206,6 +212,9 @@
|
||||
dependencies = (
|
||||
);
|
||||
name = Runner;
|
||||
packageProductDependencies = (
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
|
||||
);
|
||||
productName = Runner;
|
||||
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
@@ -239,6 +248,9 @@
|
||||
Base,
|
||||
);
|
||||
mainGroup = 97C146E51CF9000F007C117D;
|
||||
packageReferences = (
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
|
||||
);
|
||||
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
@@ -363,23 +375,6 @@
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
|
||||
};
|
||||
CD126DA202245CE8BB749787 /* [CP] Embed Pods Frameworks */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
@@ -396,6 +391,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||
8D5D9D9E2C69000100F10001 /* RecordingPlugin.swift in Sources */,
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -744,6 +740,20 @@
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = FlutterGeneratedPluginSwiftPackage;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 97C146E61CF9000F007C117D /* Project object */;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,24 @@
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<PreActions>
|
||||
<ExecutionAction
|
||||
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
|
||||
<ActionContent
|
||||
title = "Run Prepare Flutter Framework Script"
|
||||
scriptText = "/bin/sh "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" prepare ">
|
||||
<EnvironmentBuildable>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</EnvironmentBuildable>
|
||||
</ActionContent>
|
||||
</ExecutionAction>
|
||||
</PreActions>
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
|
||||
@@ -2,12 +2,18 @@ import Flutter
|
||||
import UIKit
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate {
|
||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
|
||||
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||
if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "RecordingPlugin") {
|
||||
RecordingPlugin.register(with: registrar)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 622 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 945 B |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 6.0 KiB |
@@ -2,6 +2,8 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
@@ -24,6 +26,35 @@
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>需要访问相机以显示预览并录制视频。</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>需要访问麦克风以录制视频声音;未授权时将静音录制。</string>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>需要将录制的视频保存到相册。</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
<key>UISceneConfigurations</key>
|
||||
<dict>
|
||||
<key>UIWindowSceneSessionRoleApplication</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneClassName</key>
|
||||
<string>UIWindowScene</string>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>flutter</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>FlutterSceneDelegate</string>
|
||||
<key>UISceneStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
@@ -41,9 +72,5 @@
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
597
ios/Runner/RecordingPlugin.swift
Normal file
@@ -0,0 +1,597 @@
|
||||
import AVFoundation
|
||||
import Flutter
|
||||
import Photos
|
||||
import UIKit
|
||||
|
||||
private enum RecordingState: String {
|
||||
case idle
|
||||
case previewing
|
||||
case recording
|
||||
case stopping
|
||||
case error
|
||||
}
|
||||
|
||||
private struct RecordingStatus {
|
||||
let state: RecordingState
|
||||
let outputPath: String?
|
||||
let elapsedMillis: Int
|
||||
let message: String?
|
||||
|
||||
init(
|
||||
state: RecordingState,
|
||||
outputPath: String? = nil,
|
||||
elapsedMillis: Int = 0,
|
||||
message: String? = nil
|
||||
) {
|
||||
self.state = state
|
||||
self.outputPath = outputPath
|
||||
self.elapsedMillis = elapsedMillis
|
||||
self.message = message
|
||||
}
|
||||
|
||||
func toMap() -> [String: Any] {
|
||||
var map: [String: Any] = [
|
||||
"state": state.rawValue,
|
||||
"elapsedMillis": elapsedMillis,
|
||||
]
|
||||
if let outputPath {
|
||||
map["outputPath"] = outputPath
|
||||
}
|
||||
if let message {
|
||||
map["message"] = message
|
||||
}
|
||||
return map
|
||||
}
|
||||
}
|
||||
|
||||
private final class RecordingPreviewView: UIView {
|
||||
override class var layerClass: AnyClass {
|
||||
AVCaptureVideoPreviewLayer.self
|
||||
}
|
||||
|
||||
var previewLayer: AVCaptureVideoPreviewLayer {
|
||||
layer as! AVCaptureVideoPreviewLayer
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
previewLayer.videoGravity = .resizeAspectFill
|
||||
backgroundColor = .black
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
previewLayer.videoGravity = .resizeAspectFill
|
||||
backgroundColor = .black
|
||||
}
|
||||
}
|
||||
|
||||
private final class RecordingPreviewPlatformView: NSObject, FlutterPlatformView {
|
||||
private let previewView: RecordingPreviewView
|
||||
|
||||
init(frame: CGRect) {
|
||||
previewView = RecordingPreviewView(frame: frame)
|
||||
super.init()
|
||||
RecordingCameraController.shared.attach(previewView: previewView)
|
||||
}
|
||||
|
||||
func view() -> UIView {
|
||||
previewView
|
||||
}
|
||||
|
||||
deinit {
|
||||
RecordingCameraController.shared.detach(previewView: previewView)
|
||||
}
|
||||
}
|
||||
|
||||
private final class RecordingPreviewFactory: NSObject, FlutterPlatformViewFactory {
|
||||
func create(
|
||||
withFrame frame: CGRect,
|
||||
viewIdentifier viewId: Int64,
|
||||
arguments args: Any?
|
||||
) -> FlutterPlatformView {
|
||||
RecordingPreviewPlatformView(frame: frame)
|
||||
}
|
||||
|
||||
func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol {
|
||||
FlutterStandardMessageCodec.sharedInstance()
|
||||
}
|
||||
}
|
||||
|
||||
private final class RecordingCameraController: NSObject, AVCaptureFileOutputRecordingDelegate {
|
||||
static let shared = RecordingCameraController()
|
||||
|
||||
private let session = AVCaptureSession()
|
||||
private let sessionQueue = DispatchQueue(label: "recording.camera.session")
|
||||
private let movieOutput = AVCaptureMovieFileOutput()
|
||||
|
||||
private weak var previewView: RecordingPreviewView?
|
||||
private var videoInput: AVCaptureDeviceInput?
|
||||
private var audioInput: AVCaptureDeviceInput?
|
||||
private var configured = false
|
||||
private var latestOutputPath: String?
|
||||
private var latestGallerySaved = true
|
||||
private var latestGalleryErrorMessage: String?
|
||||
private var pendingDisplayName: String?
|
||||
private var recordingStartedAt: Date?
|
||||
private var elapsedTimer: Timer?
|
||||
private var pendingStopResult: FlutterResult?
|
||||
|
||||
private(set) var status = RecordingStatus(state: .idle) {
|
||||
didSet {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.statusListener?(self.currentStatusMap())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var statusListener: (([String: Any]) -> Void)?
|
||||
|
||||
func attach(previewView: RecordingPreviewView) {
|
||||
self.previewView = previewView
|
||||
previewView.previewLayer.session = session
|
||||
}
|
||||
|
||||
func detach(previewView: RecordingPreviewView) {
|
||||
if self.previewView === previewView {
|
||||
self.previewView?.previewLayer.session = nil
|
||||
self.previewView = nil
|
||||
}
|
||||
}
|
||||
|
||||
func initializePreview(result: @escaping FlutterResult) {
|
||||
guard let previewView else {
|
||||
result(FlutterError(code: "NO_PREVIEW", message: "Camera preview is not attached", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
previewView.previewLayer.session = session
|
||||
sessionQueue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
do {
|
||||
try self.configureSession(withAudio: self.isMicrophoneAuthorized())
|
||||
if !self.session.isRunning {
|
||||
self.session.startRunning()
|
||||
}
|
||||
self.updateStatus(RecordingStatus(state: .previewing))
|
||||
DispatchQueue.main.async {
|
||||
result(self.currentStatusMap())
|
||||
}
|
||||
} catch {
|
||||
self.updateStatus(RecordingStatus(state: .error, message: error.localizedDescription))
|
||||
DispatchQueue.main.async {
|
||||
result(
|
||||
FlutterError(
|
||||
code: "PREVIEW_FAILED",
|
||||
message: error.localizedDescription,
|
||||
details: nil
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
return
|
||||
}
|
||||
|
||||
sessionQueue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
do {
|
||||
try self.configureSession(withAudio: withAudio && self.isMicrophoneAuthorized())
|
||||
if !self.session.isRunning {
|
||||
self.session.startRunning()
|
||||
}
|
||||
|
||||
guard !self.movieOutput.isRecording else {
|
||||
DispatchQueue.main.async {
|
||||
result(FlutterError(code: "START_FAILED", message: "Already recording", details: nil))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
self.pendingDisplayName = displayName
|
||||
self.latestGallerySaved = true
|
||||
self.latestGalleryErrorMessage = nil
|
||||
let outputURL = try self.createOutputURL(displayName: displayName)
|
||||
self.latestOutputPath = outputURL.lastPathComponent
|
||||
self.recordingStartedAt = Date()
|
||||
self.updateStatus(RecordingStatus(state: .recording, outputPath: outputURL.path))
|
||||
self.movieOutput.startRecording(to: outputURL, recordingDelegate: self)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.startElapsedTimer()
|
||||
result([
|
||||
"outputPath": outputURL.path,
|
||||
"status": self.currentStatusMap(),
|
||||
])
|
||||
}
|
||||
} catch {
|
||||
self.updateStatus(RecordingStatus(state: .error, message: error.localizedDescription))
|
||||
DispatchQueue.main.async {
|
||||
result(
|
||||
FlutterError(
|
||||
code: "START_FAILED",
|
||||
message: error.localizedDescription,
|
||||
details: nil
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stopRecording(result: @escaping FlutterResult) {
|
||||
sessionQueue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
guard self.movieOutput.isRecording else {
|
||||
DispatchQueue.main.async {
|
||||
var payload: [String: Any] = [
|
||||
"outputPath": self.latestOutputPath as Any,
|
||||
"status": self.currentStatusMap(),
|
||||
"gallerySaved": self.latestGallerySaved,
|
||||
]
|
||||
if !self.latestGallerySaved {
|
||||
payload["galleryErrorMessage"] =
|
||||
self.latestGalleryErrorMessage ?? "保存到相册失败"
|
||||
}
|
||||
result(payload)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
self.pendingStopResult = result
|
||||
self.updateStatus(RecordingStatus(state: .stopping, outputPath: self.latestOutputPath))
|
||||
self.movieOutput.stopRecording()
|
||||
}
|
||||
}
|
||||
|
||||
func disposePreview(result: @escaping FlutterResult) {
|
||||
sessionQueue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
if self.movieOutput.isRecording {
|
||||
self.movieOutput.stopRecording()
|
||||
}
|
||||
if self.session.isRunning {
|
||||
self.session.stopRunning()
|
||||
}
|
||||
self.session.beginConfiguration()
|
||||
for input in self.session.inputs {
|
||||
self.session.removeInput(input)
|
||||
}
|
||||
for output in self.session.outputs {
|
||||
self.session.removeOutput(output)
|
||||
}
|
||||
self.session.commitConfiguration()
|
||||
self.videoInput = nil
|
||||
self.audioInput = nil
|
||||
self.configured = false
|
||||
self.updateStatus(RecordingStatus(state: .idle))
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.stopElapsedTimer()
|
||||
result(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func currentStatusMap() -> [String: Any] {
|
||||
if status.state == .recording {
|
||||
return RecordingStatus(
|
||||
state: .recording,
|
||||
outputPath: latestOutputPath,
|
||||
elapsedMillis: elapsedMillis()
|
||||
).toMap()
|
||||
}
|
||||
return status.toMap()
|
||||
}
|
||||
|
||||
func fileOutput(
|
||||
_ output: AVCaptureFileOutput,
|
||||
didFinishRecordingTo outputFileURL: URL,
|
||||
from connections: [AVCaptureConnection],
|
||||
error: Error?
|
||||
) {
|
||||
let stopResult = pendingStopResult
|
||||
pendingStopResult = nil
|
||||
|
||||
if let error {
|
||||
latestGallerySaved = false
|
||||
latestGalleryErrorMessage = error.localizedDescription
|
||||
updateStatus(RecordingStatus(state: .error, outputPath: latestOutputPath, message: error.localizedDescription))
|
||||
finishStopRecording(stopResult: stopResult)
|
||||
return
|
||||
}
|
||||
|
||||
saveVideoToPhotoLibrary(fileURL: outputFileURL) { [weak self] success, message in
|
||||
guard let self else { return }
|
||||
self.latestGallerySaved = success
|
||||
self.latestGalleryErrorMessage = message
|
||||
if success {
|
||||
self.updateStatus(
|
||||
RecordingStatus(
|
||||
state: .previewing,
|
||||
outputPath: self.latestOutputPath,
|
||||
elapsedMillis: self.elapsedMillis()
|
||||
)
|
||||
)
|
||||
} else {
|
||||
self.updateStatus(
|
||||
RecordingStatus(
|
||||
state: .error,
|
||||
outputPath: self.latestOutputPath,
|
||||
message: message ?? "保存到相册失败"
|
||||
)
|
||||
)
|
||||
}
|
||||
self.finishStopRecording(stopResult: stopResult)
|
||||
}
|
||||
}
|
||||
|
||||
private func finishStopRecording(stopResult: FlutterResult?) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.stopElapsedTimer()
|
||||
var payload: [String: Any] = [
|
||||
"outputPath": self.latestOutputPath as Any,
|
||||
"status": self.currentStatusMap(),
|
||||
"gallerySaved": self.latestGallerySaved,
|
||||
]
|
||||
if !self.latestGallerySaved {
|
||||
payload["galleryErrorMessage"] =
|
||||
self.latestGalleryErrorMessage ?? "保存到相册失败,请开启相册权限"
|
||||
}
|
||||
stopResult?(payload)
|
||||
}
|
||||
}
|
||||
|
||||
private func saveVideoToPhotoLibrary(
|
||||
fileURL: URL,
|
||||
completion: @escaping (Bool, String?) -> Void
|
||||
) {
|
||||
let performSave = {
|
||||
PHPhotoLibrary.shared().performChanges({
|
||||
PHAssetCreationRequest.forAsset().addResource(with: .video, fileURL: fileURL, options: nil)
|
||||
}) { success, error in
|
||||
if success {
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
completion(true, nil)
|
||||
} else {
|
||||
completion(false, error?.localizedDescription ?? "保存到相册失败")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if #available(iOS 14, *) {
|
||||
let status = PHPhotoLibrary.authorizationStatus(for: .addOnly)
|
||||
switch status {
|
||||
case .authorized, .limited:
|
||||
performSave()
|
||||
case .notDetermined:
|
||||
PHPhotoLibrary.requestAuthorization(for: .addOnly) { newStatus in
|
||||
if newStatus == .authorized || newStatus == .limited {
|
||||
performSave()
|
||||
} else {
|
||||
completion(false, "未授予相册权限")
|
||||
}
|
||||
}
|
||||
default:
|
||||
completion(false, "未授予相册权限")
|
||||
}
|
||||
} else {
|
||||
let status = PHPhotoLibrary.authorizationStatus()
|
||||
switch status {
|
||||
case .authorized:
|
||||
performSave()
|
||||
case .notDetermined:
|
||||
PHPhotoLibrary.requestAuthorization { newStatus in
|
||||
if newStatus == .authorized {
|
||||
performSave()
|
||||
} else {
|
||||
completion(false, "未授予相册权限")
|
||||
}
|
||||
}
|
||||
default:
|
||||
completion(false, "未授予相册权限")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func configureSession(withAudio: Bool) throws {
|
||||
if configured {
|
||||
try configureAudioInput(enabled: withAudio)
|
||||
return
|
||||
}
|
||||
|
||||
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"])
|
||||
}
|
||||
|
||||
let nextVideoInput = try AVCaptureDeviceInput(device: videoDevice)
|
||||
|
||||
session.beginConfiguration()
|
||||
session.sessionPreset = .high
|
||||
|
||||
guard session.canAddInput(nextVideoInput) else {
|
||||
session.commitConfiguration()
|
||||
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"])
|
||||
}
|
||||
session.addOutput(movieOutput)
|
||||
session.commitConfiguration()
|
||||
|
||||
configured = true
|
||||
try configureAudioInput(enabled: withAudio)
|
||||
}
|
||||
|
||||
private func configureAudioInput(enabled: Bool) throws {
|
||||
session.beginConfiguration()
|
||||
defer { session.commitConfiguration() }
|
||||
|
||||
if let audioInput {
|
||||
session.removeInput(audioInput)
|
||||
self.audioInput = nil
|
||||
}
|
||||
|
||||
guard enabled else { return }
|
||||
guard let audioDevice = AVCaptureDevice.default(for: .audio) else { return }
|
||||
|
||||
let nextAudioInput = try AVCaptureDeviceInput(device: audioDevice)
|
||||
if session.canAddInput(nextAudioInput) {
|
||||
session.addInput(nextAudioInput)
|
||||
audioInput = nextAudioInput
|
||||
}
|
||||
}
|
||||
|
||||
private func isMicrophoneAuthorized() -> Bool {
|
||||
AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
|
||||
}
|
||||
|
||||
private func createOutputURL(displayName: String?) throws -> URL {
|
||||
let baseURL = try FileManager.default.url(
|
||||
for: .documentDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
let recordingsURL = baseURL.appendingPathComponent("recordings", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: recordingsURL, withIntermediateDirectories: true)
|
||||
|
||||
let fileName = Self.resolveFileName(displayName: displayName)
|
||||
return recordingsURL.appendingPathComponent(fileName)
|
||||
}
|
||||
|
||||
private static func resolveFileName(displayName: String?) -> String {
|
||||
let trimmed = displayName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !trimmed.isEmpty {
|
||||
let lower = trimmed.lowercased()
|
||||
if lower.hasSuffix(".mov") || lower.hasSuffix(".mp4") {
|
||||
return trimmed
|
||||
}
|
||||
return "\(trimmed).mov"
|
||||
}
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
||||
return "REC_\(formatter.string(from: Date())).mov"
|
||||
}
|
||||
|
||||
private func updateStatus(_ next: RecordingStatus) {
|
||||
status = next
|
||||
}
|
||||
|
||||
private func elapsedMillis() -> Int {
|
||||
guard let recordingStartedAt else { return 0 }
|
||||
return max(0, Int(Date().timeIntervalSince(recordingStartedAt) * 1000))
|
||||
}
|
||||
|
||||
private func startElapsedTimer() {
|
||||
stopElapsedTimer()
|
||||
elapsedTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
|
||||
guard let self, self.status.state == .recording else { return }
|
||||
self.statusListener?(self.currentStatusMap())
|
||||
}
|
||||
}
|
||||
|
||||
private func stopElapsedTimer() {
|
||||
elapsedTimer?.invalidate()
|
||||
elapsedTimer = nil
|
||||
}
|
||||
}
|
||||
|
||||
private enum RecordingChannelNames {
|
||||
static let packageName = "com.gdfw.fxjk"
|
||||
static let method = "\(packageName)/recording"
|
||||
static let events = "\(packageName)/recording_events"
|
||||
}
|
||||
|
||||
final class RecordingPlugin: NSObject, FlutterPlugin, FlutterStreamHandler {
|
||||
private let controller = RecordingCameraController.shared
|
||||
private var eventSink: FlutterEventSink?
|
||||
|
||||
static func register(with registrar: FlutterPluginRegistrar) {
|
||||
let plugin = RecordingPlugin()
|
||||
let messenger = registrar.messenger()
|
||||
|
||||
registrar.register(RecordingPreviewFactory(), withId: "recording-camera-preview")
|
||||
|
||||
let methodChannel = FlutterMethodChannel(
|
||||
name: RecordingChannelNames.method,
|
||||
binaryMessenger: messenger
|
||||
)
|
||||
registrar.addMethodCallDelegate(plugin, channel: methodChannel)
|
||||
|
||||
let eventChannel = FlutterEventChannel(
|
||||
name: RecordingChannelNames.events,
|
||||
binaryMessenger: messenger
|
||||
)
|
||||
eventChannel.setStreamHandler(plugin)
|
||||
|
||||
plugin.controller.statusListener = { [weak plugin] status in
|
||||
plugin?.eventSink?(status)
|
||||
}
|
||||
}
|
||||
|
||||
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
switch call.method {
|
||||
case "initializePreview":
|
||||
controller.initializePreview(result: result)
|
||||
case "startRecording":
|
||||
let args = call.arguments as? [String: Any]
|
||||
let withAudio = args?["withAudio"] as? Bool ?? true
|
||||
let displayName = args?["displayName"] as? String
|
||||
controller.startRecording(withAudio: withAudio, displayName: displayName, result: result)
|
||||
case "stopRecording":
|
||||
controller.stopRecording(result: result)
|
||||
case "disposePreview":
|
||||
controller.disposePreview(result: result)
|
||||
case "getStatus":
|
||||
result(controller.currentStatusMap())
|
||||
case "hasNotificationPolicyAccess":
|
||||
result(true)
|
||||
case "openNotificationPolicySettings":
|
||||
result(nil)
|
||||
case "enableDoNotDisturb":
|
||||
result(false)
|
||||
case "disableDoNotDisturb":
|
||||
result(nil)
|
||||
case "isIgnoringBatteryOptimizations":
|
||||
result(true)
|
||||
case "openBatteryOptimizationSettings":
|
||||
result(nil)
|
||||
case "setImmersiveMode":
|
||||
result(nil)
|
||||
case "isForegroundServiceRunning":
|
||||
result(false)
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
|
||||
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
|
||||
eventSink = events
|
||||
events(controller.currentStatusMap())
|
||||
return nil
|
||||
}
|
||||
|
||||
func onCancel(withArguments arguments: Any?) -> FlutterError? {
|
||||
eventSink = nil
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,46 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_easyloading/flutter_easyloading.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:flutter_template/app/config/app_config.dart';
|
||||
import 'package:flutter_template/app/router/app_navigator.dart';
|
||||
import 'package:flutter_template/app/theme/app_theme.dart';
|
||||
import 'package:flutter_template/features/demo/demo_page.dart';
|
||||
import 'package:recording_tool/app/config/app_config.dart';
|
||||
import 'package:recording_tool/app/router/app_navigator.dart';
|
||||
import 'package:recording_tool/app/theme/app_theme.dart';
|
||||
import 'package:recording_tool/features/recording/recording_page.dart';
|
||||
import 'package:recording_tool/features/recording/view-model/view_model_recording.dart';
|
||||
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
||||
|
||||
class FlutterTemplateApp extends StatelessWidget {
|
||||
class FlutterTemplateApp extends ConsumerStatefulWidget {
|
||||
const FlutterTemplateApp({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<FlutterTemplateApp> createState() => _FlutterTemplateAppState();
|
||||
}
|
||||
|
||||
class _FlutterTemplateAppState extends ConsumerState<FlutterTemplateApp>
|
||||
with WidgetsBindingObserver {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ref.read(recordingViewModelProvider.notifier).getClipboardContent();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
ref.read(recordingViewModelProvider.notifier).getClipboardContent();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ScreenUtilInit(
|
||||
@@ -44,7 +74,7 @@ class FlutterTemplateApp extends StatelessWidget {
|
||||
home: RefreshConfiguration(
|
||||
enableLoadingWhenNoData: false,
|
||||
headerTriggerDistance: 80,
|
||||
child: const DemoPage(),
|
||||
child: const RecordingPage(),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_template/app/app.dart';
|
||||
import 'package:flutter_template/app/config/app_config.dart';
|
||||
import 'package:flutter_template/core/cache/app_storage.dart';
|
||||
import 'package:flutter_template/core/logging/app_logger.dart';
|
||||
import 'package:recording_tool/app/app.dart';
|
||||
import 'package:recording_tool/app/config/app_config.dart';
|
||||
import 'package:recording_tool/core/cache/app_storage.dart';
|
||||
import 'package:recording_tool/core/logging/app_logger.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
class AppBootstrapper {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_template/core/network/api_exception.dart';
|
||||
import 'package:flutter_template/core/network/api_response.dart';
|
||||
import 'package:flutter_template/core/network/http_method.dart';
|
||||
import 'package:recording_tool/core/network/api_exception.dart';
|
||||
import 'package:recording_tool/core/network/api_response.dart';
|
||||
import 'package:recording_tool/core/network/http_method.dart';
|
||||
|
||||
typedef JsonParser<T> = T Function(dynamic json);
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_template/app/config/app_config.dart';
|
||||
import 'package:flutter_template/core/cache/app_storage.dart';
|
||||
import 'package:flutter_template/core/cache/storage_keys.dart';
|
||||
import 'package:flutter_template/core/utils/device_utils.dart';
|
||||
import 'package:recording_tool/app/config/app_config.dart';
|
||||
import 'package:recording_tool/core/cache/app_storage.dart';
|
||||
import 'package:recording_tool/core/cache/storage_keys.dart';
|
||||
import 'package:recording_tool/core/utils/device_utils.dart';
|
||||
|
||||
class HeaderInterceptor extends Interceptor {
|
||||
@override
|
||||
|
||||
@@ -3,7 +3,7 @@ import 'dart:async';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_template/core/network/network_state.dart';
|
||||
import 'package:recording_tool/core/network/network_state.dart';
|
||||
|
||||
class NetworkMonitor {
|
||||
final _controller = StreamController<NetworkState>.broadcast();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_template/core/network/network_monitor.dart';
|
||||
import 'package:flutter_template/core/network/offline_queue/offline_queue_manager.dart';
|
||||
import 'package:flutter_template/core/network/offline_queue/offline_request.dart';
|
||||
import 'package:recording_tool/core/network/network_monitor.dart';
|
||||
import 'package:recording_tool/core/network/offline_queue/offline_queue_manager.dart';
|
||||
import 'package:recording_tool/core/network/offline_queue/offline_request.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
class OfflineQueueInterceptor extends Interceptor {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_template/core/logging/app_logger.dart';
|
||||
import 'package:flutter_template/core/network/network_monitor.dart';
|
||||
import 'package:flutter_template/core/network/offline_queue/offline_queue_state.dart';
|
||||
import 'package:flutter_template/core/network/offline_queue/offline_queue_storage.dart';
|
||||
import 'package:flutter_template/core/network/offline_queue/offline_request.dart';
|
||||
import 'package:recording_tool/core/logging/app_logger.dart';
|
||||
import 'package:recording_tool/core/network/network_monitor.dart';
|
||||
import 'package:recording_tool/core/network/offline_queue/offline_queue_state.dart';
|
||||
import 'package:recording_tool/core/network/offline_queue/offline_queue_storage.dart';
|
||||
import 'package:recording_tool/core/network/offline_queue/offline_request.dart';
|
||||
|
||||
class OfflineQueueManager {
|
||||
OfflineQueueManager({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter_template/core/cache/app_storage.dart';
|
||||
import 'package:flutter_template/core/cache/storage_keys.dart';
|
||||
import 'package:flutter_template/core/network/offline_queue/offline_request.dart';
|
||||
import 'package:recording_tool/core/cache/app_storage.dart';
|
||||
import 'package:recording_tool/core/cache/storage_keys.dart';
|
||||
import 'package:recording_tool/core/network/offline_queue/offline_request.dart';
|
||||
|
||||
class OfflineQueueStorage {
|
||||
Future<List<OfflineRequest>> loadQueue() async {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_template/app/config/app_config.dart';
|
||||
import 'package:flutter_template/core/network/api_client.dart';
|
||||
import 'package:flutter_template/core/network/header_interceptor.dart';
|
||||
import 'package:flutter_template/core/network/offline_queue/offline_queue_interceptor.dart';
|
||||
import 'package:flutter_template/core/network/providers/network_providers.dart';
|
||||
import 'package:flutter_template/core/network/providers/offline_queue_providers.dart';
|
||||
import 'package:recording_tool/app/config/app_config.dart';
|
||||
import 'package:recording_tool/core/network/api_client.dart';
|
||||
import 'package:recording_tool/core/network/header_interceptor.dart';
|
||||
import 'package:recording_tool/core/network/offline_queue/offline_queue_interceptor.dart';
|
||||
import 'package:recording_tool/core/network/providers/network_providers.dart';
|
||||
import 'package:recording_tool/core/network/providers/offline_queue_providers.dart';
|
||||
|
||||
final dioProvider = Provider<Dio>((ref) {
|
||||
final dio = Dio(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_template/core/network/network_monitor.dart';
|
||||
import 'package:flutter_template/core/network/network_state.dart';
|
||||
import 'package:recording_tool/core/network/network_monitor.dart';
|
||||
import 'package:recording_tool/core/network/network_state.dart';
|
||||
|
||||
final networkMonitorProvider = Provider<NetworkMonitor>((ref) {
|
||||
final monitor = NetworkMonitor()..start();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_template/core/network/offline_queue/offline_queue_manager.dart';
|
||||
import 'package:flutter_template/core/network/offline_queue/offline_queue_state.dart';
|
||||
import 'package:flutter_template/core/network/offline_queue/offline_queue_storage.dart';
|
||||
import 'package:flutter_template/core/network/providers/network_providers.dart';
|
||||
import 'package:recording_tool/core/network/offline_queue/offline_queue_manager.dart';
|
||||
import 'package:recording_tool/core/network/offline_queue/offline_queue_state.dart';
|
||||
import 'package:recording_tool/core/network/offline_queue/offline_queue_storage.dart';
|
||||
import 'package:recording_tool/core/network/providers/network_providers.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
final offlineQueueStorageProvider = Provider<OfflineQueueStorage>((ref) {
|
||||
|
||||
@@ -17,6 +17,24 @@ class PermissionService {
|
||||
return permissions.toList().request();
|
||||
}
|
||||
|
||||
/// 仅对尚未授予的权限发起系统授权弹窗,已授予则直接返回当前状态。
|
||||
static Future<Map<Permission, PermissionStatus>> requestMissing(
|
||||
Iterable<Permission> permissions,
|
||||
) async {
|
||||
final result = <Permission, PermissionStatus>{};
|
||||
for (final permission in permissions) {
|
||||
final current = await permission.status;
|
||||
if (current.isGranted ||
|
||||
current.isLimited ||
|
||||
current.isPermanentlyDenied) {
|
||||
result[permission] = current;
|
||||
continue;
|
||||
}
|
||||
result[permission] = await permission.request();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static Future<bool> ensure(
|
||||
Permission permission, {
|
||||
bool openSettingsWhenPermanentlyDenied = true,
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class DemoState {
|
||||
const DemoState({this.count = 0, this.query = ''});
|
||||
|
||||
final int count;
|
||||
final String query;
|
||||
|
||||
DemoState copyWith({int? count, String? query}) {
|
||||
return DemoState(count: count ?? this.count, query: query ?? this.query);
|
||||
}
|
||||
}
|
||||
|
||||
class DemoController extends Notifier<DemoState> {
|
||||
@override
|
||||
DemoState build() => const DemoState();
|
||||
|
||||
void increment() {
|
||||
state = state.copyWith(count: state.count + 1);
|
||||
}
|
||||
|
||||
void updateQuery(String query) {
|
||||
state = state.copyWith(query: query);
|
||||
}
|
||||
}
|
||||
|
||||
final demoControllerProvider = NotifierProvider<DemoController, DemoState>(
|
||||
DemoController.new,
|
||||
);
|
||||
@@ -1,130 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_template/app/config/app_config.dart';
|
||||
import 'package:flutter_template/app/theme/app_theme.dart';
|
||||
import 'package:flutter_template/features/demo/demo_controller.dart';
|
||||
import 'package:flutter_template/features/recording/recording_page.dart';
|
||||
import 'package:flutter_template/shared/widgets/widgets.dart';
|
||||
|
||||
class DemoPage extends ConsumerWidget {
|
||||
const DemoPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(demoControllerProvider);
|
||||
final controller = ref.read(demoControllerProvider.notifier);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text(AppConfig.appName)),
|
||||
body: SafeAreaWrapper(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
children: [
|
||||
AppSearchBar(hint: '搜索模板组件', onChanged: controller.updateQuery),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
AppCard(
|
||||
child: Row(
|
||||
children: [
|
||||
const AppAvatar(initials: 'T', size: 48),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'通用 Flutter 快速开发模板',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'已内置网络、缓存、路由、主题、权限、日志和常用 UI 组件。',
|
||||
style: Theme.of(context).textTheme.bodyMedium
|
||||
?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: const [
|
||||
AppTag(label: 'Riverpod', tone: AppTagTone.info),
|
||||
AppTag(label: 'Dio', tone: AppTagTone.success),
|
||||
AppTag(label: '缓存', tone: AppTagTone.warning),
|
||||
AppTag(label: '无业务代码'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
AppCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'状态管理示例',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Text('当前计数:${state.count}'),
|
||||
if (state.query.isNotEmpty) ...[
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Text('搜索关键字:${state.query}'),
|
||||
],
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
AppButton(
|
||||
label: '增加计数',
|
||||
icon: const Icon(Icons.add, size: 18),
|
||||
onPressed: controller.increment,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
AppButton(
|
||||
label: '打开录制',
|
||||
icon: const Icon(Icons.videocam, size: 18),
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => const RecordingPage(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
AppStatusView(
|
||||
status: AppViewStatus.empty,
|
||||
empty: AppEmptyView(
|
||||
title: '空状态组件',
|
||||
message: '业务项目可替换图标、文案和操作按钮。',
|
||||
action: AppButton(
|
||||
label: '显示确认弹窗',
|
||||
variant: AppButtonVariant.outline,
|
||||
icon: const Icon(Icons.open_in_new, size: 18),
|
||||
onPressed: () async {
|
||||
final confirmed = await AppDialog.confirm(
|
||||
context,
|
||||
title: '模板弹窗',
|
||||
message: '这是可复用的确认弹窗示例。',
|
||||
);
|
||||
if (confirmed == true) {
|
||||
AppToast.show('已确认');
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
child: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
60
lib/features/recording/model/model_clipboard.dart
Normal file
@@ -0,0 +1,60 @@
|
||||
/// 小程序复制到剪切板的录制信息。
|
||||
class ClipboardRecordingModel {
|
||||
final String title;
|
||||
final int startTimestamp;
|
||||
final int endTimestamp;
|
||||
final String address;
|
||||
|
||||
/// 录制文件名模板,如「选手名称_选手ID_赛事名称_赛项」。
|
||||
final String? filename;
|
||||
|
||||
ClipboardRecordingModel({
|
||||
required this.title,
|
||||
required this.startTimestamp,
|
||||
required this.endTimestamp,
|
||||
required this.address,
|
||||
this.filename,
|
||||
});
|
||||
|
||||
factory ClipboardRecordingModel.fromJson(Map<String, dynamic> json) {
|
||||
return ClipboardRecordingModel(
|
||||
title: _readString(json, 'title'),
|
||||
startTimestamp: _readInt(json, 'startTimestamp'),
|
||||
endTimestamp: _readInt(json, 'endTimestamp'),
|
||||
address: _readString(json, 'address'),
|
||||
filename: _readOptionalString(json, 'filename'),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'title': title,
|
||||
'startTimestamp': startTimestamp,
|
||||
'endTimestamp': endTimestamp,
|
||||
'address': address,
|
||||
if (filename != null) 'filename': filename,
|
||||
};
|
||||
}
|
||||
|
||||
static String? _readOptionalString(Map<String, dynamic> json, String key) {
|
||||
final value = json[key];
|
||||
if (value == null) return null;
|
||||
if (value is String && value.isNotEmpty) return value;
|
||||
if (value is! String) {
|
||||
throw FormatException('Clipboard field "$key" must be a String.');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String _readString(Map<String, dynamic> json, String key) {
|
||||
final value = json[key];
|
||||
if (value is String) return value;
|
||||
throw FormatException('Clipboard field "$key" must be a String.');
|
||||
}
|
||||
|
||||
static int _readInt(Map<String, dynamic> json, String key) {
|
||||
final value = json[key];
|
||||
if (value is int) return value;
|
||||
throw FormatException('Clipboard field "$key" must be an int.');
|
||||
}
|
||||
}
|
||||
37
lib/features/recording/model/model_recording.dart
Normal file
@@ -0,0 +1,37 @@
|
||||
import 'package:recording_tool/features/recording/model/model_clipboard.dart';
|
||||
|
||||
class RecordingModel {
|
||||
/// 剪切板内容
|
||||
final ClipboardRecordingModel clipboardRecordingModel;
|
||||
|
||||
/// 剪切板是否包含有效的小程序录制信息
|
||||
final bool hasValidClipboardInfo;
|
||||
|
||||
RecordingModel({
|
||||
required this.clipboardRecordingModel,
|
||||
this.hasValidClipboardInfo = false,
|
||||
});
|
||||
|
||||
factory RecordingModel.fromJson(Map<String, dynamic> json) {
|
||||
return RecordingModel(
|
||||
clipboardRecordingModel: ClipboardRecordingModel.fromJson(
|
||||
json['clipboardRecordingModel'],
|
||||
),
|
||||
);
|
||||
}
|
||||
Map<String, dynamic> toJson() {
|
||||
return {'clipboardRecordingModel': clipboardRecordingModel.toJson()};
|
||||
}
|
||||
|
||||
RecordingModel copyWith({
|
||||
ClipboardRecordingModel? clipboardRecordingModel,
|
||||
bool? hasValidClipboardInfo,
|
||||
}) {
|
||||
return RecordingModel(
|
||||
clipboardRecordingModel:
|
||||
clipboardRecordingModel ?? this.clipboardRecordingModel,
|
||||
hasValidClipboardInfo:
|
||||
hasValidClipboardInfo ?? this.hasValidClipboardInfo,
|
||||
);
|
||||
}
|
||||
}
|
||||
5
lib/features/recording/recording_channel_names.dart
Normal file
@@ -0,0 +1,5 @@
|
||||
abstract final class RecordingChannelNames {
|
||||
static const packageName = 'com.gdfw.fxjk';
|
||||
static const method = '$packageName/recording';
|
||||
static const events = '$packageName/recording_events';
|
||||
}
|
||||
53
lib/features/recording/recording_display_name.dart
Normal file
@@ -0,0 +1,53 @@
|
||||
import 'dart:io';
|
||||
|
||||
/// 非法文件名字符(路径分隔符等)。
|
||||
final _invalidNameChars = RegExp(r'[/\\:*?"<>|]');
|
||||
|
||||
const _maxBaseNameLength = 120;
|
||||
|
||||
/// 清洗小程序复制的文件名基底(不含扩展名)。
|
||||
String? sanitizeRecordingBaseName(String raw) {
|
||||
var name = raw.replaceAll(_invalidNameChars, '_').trim();
|
||||
if (name.isEmpty) return null;
|
||||
if (name.length > _maxBaseNameLength) {
|
||||
name = name.substring(0, _maxBaseNameLength);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/// 解析录制展示名:优先剪切板 filename,否则 REC_时间戳。
|
||||
String resolveRecordingDisplayName(String? clipboardFilename) {
|
||||
final sanitized = clipboardFilename == null
|
||||
? null
|
||||
: sanitizeRecordingBaseName(clipboardFilename);
|
||||
if (sanitized != null) return sanitized;
|
||||
final now = DateTime.now();
|
||||
final stamp =
|
||||
'${now.year}'
|
||||
'${now.month.toString().padLeft(2, '0')}'
|
||||
'${now.day.toString().padLeft(2, '0')}_'
|
||||
'${now.hour.toString().padLeft(2, '0')}'
|
||||
'${now.minute.toString().padLeft(2, '0')}'
|
||||
'${now.second.toString().padLeft(2, '0')}';
|
||||
return 'REC_$stamp';
|
||||
}
|
||||
|
||||
/// 为展示名补全视频扩展名(已有 .mp4/.mov 则保留)。
|
||||
String withVideoExtension(String baseName, {bool? isIOS}) {
|
||||
final ios = isIOS ?? Platform.isIOS;
|
||||
final ext = ios ? '.mov' : '.mp4';
|
||||
final lower = baseName.toLowerCase();
|
||||
if (lower.endsWith('.mp4') || lower.endsWith('.mov')) {
|
||||
return baseName;
|
||||
}
|
||||
return '$baseName$ext';
|
||||
}
|
||||
|
||||
/// 传给原生的完整文件名(含扩展名)。
|
||||
String recordingFileNameForPlatform(
|
||||
String? clipboardFilename, {
|
||||
bool? isIOS,
|
||||
}) {
|
||||
final base = resolveRecordingDisplayName(clipboardFilename);
|
||||
return withVideoExtension(base, isIOS: isIOS);
|
||||
}
|
||||
@@ -3,12 +3,13 @@ import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_template/features/recording/recording_platform.dart';
|
||||
import 'package:flutter_template/features/recording/recording_session_controller.dart';
|
||||
import 'package:flutter_template/features/recording/widgets/camera_preview_widget.dart';
|
||||
import 'package:flutter_template/features/recording/widgets/recording_touch_lock_overlay.dart';
|
||||
import 'package:flutter_template/shared/widgets/widgets.dart';
|
||||
import 'package:permission_handler/permission_handler.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_touch_lock_overlay.dart';
|
||||
import 'package:recording_tool/shared/widgets/widgets.dart';
|
||||
|
||||
class RecordingPage extends ConsumerStatefulWidget {
|
||||
const RecordingPage({super.key});
|
||||
@@ -27,11 +28,20 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
||||
}
|
||||
|
||||
Future<void> _bootstrap() async {
|
||||
final clipboardResult = await ref
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.getClipboardContent();
|
||||
if (!mounted) return;
|
||||
if (clipboardResult == ClipboardReadResult.invalid) {
|
||||
AppToast.show('无选手信息');
|
||||
}
|
||||
await _enterRecordingMode();
|
||||
// Allow PlatformView to attach before binding CameraX preview.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 400));
|
||||
if (!mounted) return;
|
||||
await ref.read(recordingSessionControllerProvider.notifier).prepareSession();
|
||||
await ref
|
||||
.read(recordingSessionControllerProvider.notifier)
|
||||
.prepareSession();
|
||||
}
|
||||
|
||||
Future<void> _enterRecordingMode() async {
|
||||
@@ -67,7 +77,10 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(recordingSessionControllerProvider);
|
||||
final recordingInfo = ref.watch(recordingViewModelProvider);
|
||||
final controller = ref.read(recordingSessionControllerProvider.notifier);
|
||||
final clipboard = recordingInfo.clipboardRecordingModel;
|
||||
final showClipboardInfo = recordingInfo.hasValidClipboardInfo;
|
||||
|
||||
return PopScope(
|
||||
canPop: !state.isRecording,
|
||||
@@ -93,8 +106,17 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
||||
),
|
||||
_RecordingHud(
|
||||
state: state,
|
||||
eventTitle: showClipboardInfo ? clipboard.title : null,
|
||||
eventAddress: showClipboardInfo ? clipboard.address : null,
|
||||
onStart: () => controller.startRecording(),
|
||||
onStop: () => controller.stopRecording(),
|
||||
onStop: () async {
|
||||
await controller.stopRecording();
|
||||
if (!context.mounted) return;
|
||||
final latest = ref.read(recordingSessionControllerProvider);
|
||||
if (latest.gallerySaveFailed) {
|
||||
AppToast.show(latest.errorMessage ?? '保存到相册失败,请开启相册权限');
|
||||
}
|
||||
},
|
||||
onOpenDnd: () async {
|
||||
await controller.openDndSettings();
|
||||
await controller.refreshDndAccess();
|
||||
@@ -117,6 +139,8 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
||||
class _RecordingHud extends StatelessWidget {
|
||||
const _RecordingHud({
|
||||
required this.state,
|
||||
this.eventTitle,
|
||||
this.eventAddress,
|
||||
required this.onStart,
|
||||
required this.onStop,
|
||||
required this.onOpenDnd,
|
||||
@@ -125,118 +149,166 @@ class _RecordingHud extends StatelessWidget {
|
||||
});
|
||||
|
||||
final RecordingSessionState state;
|
||||
final String? eventTitle;
|
||||
final String? eventAddress;
|
||||
final VoidCallback onStart;
|
||||
final VoidCallback onStop;
|
||||
final VoidCallback onOpenDnd;
|
||||
final VoidCallback onOpenBattery;
|
||||
final VoidCallback onToggleTouchLock;
|
||||
|
||||
static const _overlayTextStyle = TextStyle(
|
||||
color: Colors.white,
|
||||
shadows: [Shadow(color: Colors.black54, blurRadius: 6)],
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
child: Stack(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: state.isRecording
|
||||
? null
|
||||
: () => Navigator.of(context).maybePop(),
|
||||
icon: const Icon(Icons.close, color: Colors.white),
|
||||
Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: eventTitle != null || state.isRecording ? 56 : 8,
|
||||
),
|
||||
const Spacer(),
|
||||
if (state.errorMessage != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Text(
|
||||
state.errorMessage!,
|
||||
style: const TextStyle(color: Colors.amber),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (state.isRecording)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
if (state.permissionWarning != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Text(
|
||||
state.permissionWarning!,
|
||||
style: const TextStyle(
|
||||
color: Colors.orangeAccent,
|
||||
fontSize: 12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
'REC ${state.elapsedLabel}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
_SetupHints(
|
||||
hasDndAccess: state.hasDndAccess,
|
||||
isBatteryIgnored: state.isBatteryOptimizedIgnored,
|
||||
notificationsGranted: state.notificationsGranted,
|
||||
onOpenDnd: onOpenDnd,
|
||||
onOpenBattery: onOpenBattery,
|
||||
onOpenNotificationSettings: openAppSettings,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
if (state.isRecording)
|
||||
IconButton(
|
||||
onPressed: onToggleTouchLock,
|
||||
icon: Icon(
|
||||
state.isTouchLocked ? Icons.lock : Icons.lock_open,
|
||||
color: Colors.white,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: state.isRecording ? onStop : onStart,
|
||||
child: Container(
|
||||
width: 76,
|
||||
height: 76,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 4),
|
||||
color: state.isRecording ? Colors.white : Colors.red,
|
||||
),
|
||||
child: Icon(
|
||||
state.isRecording
|
||||
? Icons.stop
|
||||
: Icons.fiber_manual_record,
|
||||
color: state.isRecording ? Colors.red : Colors.white,
|
||||
size: 36,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (state.errorMessage != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Text(
|
||||
state.errorMessage!,
|
||||
style: const TextStyle(color: Colors.amber),
|
||||
textAlign: TextAlign.center,
|
||||
const SizedBox(width: 48),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (state.permissionWarning != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Text(
|
||||
state.permissionWarning!,
|
||||
style: const TextStyle(color: Colors.orangeAccent, fontSize: 12),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
_SetupHints(
|
||||
hasDndAccess: state.hasDndAccess,
|
||||
isBatteryIgnored: state.isBatteryOptimizedIgnored,
|
||||
notificationsGranted: state.notificationsGranted,
|
||||
onOpenDnd: onOpenDnd,
|
||||
onOpenBattery: onOpenBattery,
|
||||
onOpenNotificationSettings: openAppSettings,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
if (state.isRecording)
|
||||
IconButton(
|
||||
onPressed: onToggleTouchLock,
|
||||
icon: Icon(
|
||||
state.isTouchLocked ? Icons.lock : Icons.lock_open,
|
||||
color: Colors.white,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: state.isRecording ? onStop : onStart,
|
||||
child: Container(
|
||||
width: 76,
|
||||
height: 76,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 4),
|
||||
color: state.isRecording ? Colors.white : Colors.red,
|
||||
),
|
||||
child: Icon(
|
||||
state.isRecording ? Icons.stop : Icons.fiber_manual_record,
|
||||
color: state.isRecording ? Colors.red : Colors.white,
|
||||
size: 36,
|
||||
),
|
||||
if (state.lastSavedDisplayName != null &&
|
||||
!state.isRecording &&
|
||||
!state.gallerySaveFailed)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Text(
|
||||
'已保存到相册:${state.lastSavedDisplayName}',
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 12),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 48),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
if (state.lastOutputPath != null && !state.isRecording)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
if (eventTitle != null)
|
||||
Positioned(
|
||||
top: 8,
|
||||
left: 12,
|
||||
right: 12,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(right: state.isRecording ? 96 : 0),
|
||||
child: Text(
|
||||
eventTitle!,
|
||||
style: _overlayTextStyle.copyWith(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (state.isRecording)
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 12,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
'REC ${state.elapsedLabel}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (eventAddress != null && eventAddress!.isNotEmpty)
|
||||
Positioned(
|
||||
left: 16,
|
||||
bottom: 108,
|
||||
right: 120,
|
||||
child: Text(
|
||||
'已保存:${state.lastOutputPath}',
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 12),
|
||||
textAlign: TextAlign.center,
|
||||
eventAddress!,
|
||||
style: _overlayTextStyle.copyWith(
|
||||
fontSize: 13,
|
||||
color: Colors.white70,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -280,16 +352,10 @@ class _SetupHints extends StatelessWidget {
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
if (!hasDndAccess)
|
||||
_HintChip(
|
||||
label: '开启勿扰权限可减少录制中断',
|
||||
onTap: onOpenDnd,
|
||||
),
|
||||
_HintChip(label: '开启勿扰权限可减少录制中断', onTap: onOpenDnd),
|
||||
if (!isBatteryIgnored) ...[
|
||||
const SizedBox(height: 8),
|
||||
_HintChip(
|
||||
label: '关闭电池优化可提升息屏续录稳定性',
|
||||
onTap: onOpenBattery,
|
||||
),
|
||||
_HintChip(label: '关闭电池优化可提升息屏续录稳定性', onTap: onOpenBattery),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:recording_tool/features/recording/recording_channel_names.dart';
|
||||
|
||||
enum RecordingState {
|
||||
idle,
|
||||
@@ -47,13 +48,18 @@ class RecordingPlatform {
|
||||
RecordingPlatform._();
|
||||
|
||||
static const MethodChannel _channel = MethodChannel(
|
||||
'com.example.flutter_template/recording',
|
||||
RecordingChannelNames.method,
|
||||
);
|
||||
static const EventChannel _events = EventChannel(
|
||||
'com.example.flutter_template/recording_events',
|
||||
RecordingChannelNames.events,
|
||||
);
|
||||
|
||||
static bool get isSupported => Platform.isAndroid;
|
||||
static bool get isSupported =>
|
||||
supportsHost(isAndroid: Platform.isAndroid, isIOS: Platform.isIOS);
|
||||
|
||||
static bool supportsHost({required bool isAndroid, required bool isIOS}) {
|
||||
return isAndroid || isIOS;
|
||||
}
|
||||
|
||||
static Stream<RecordingStatus>? _statusStream;
|
||||
|
||||
@@ -61,9 +67,10 @@ class RecordingPlatform {
|
||||
if (!isSupported) {
|
||||
return const Stream.empty();
|
||||
}
|
||||
_statusStream ??= _events
|
||||
.receiveBroadcastStream()
|
||||
.map((event) => RecordingStatus.fromMap(Map<dynamic, dynamic>.from(event as Map)));
|
||||
_statusStream ??= _events.receiveBroadcastStream().map(
|
||||
(event) =>
|
||||
RecordingStatus.fromMap(Map<dynamic, dynamic>.from(event as Map)),
|
||||
);
|
||||
return _statusStream!;
|
||||
}
|
||||
|
||||
@@ -77,12 +84,14 @@ class RecordingPlatform {
|
||||
static Future<RecordingStartResult> startRecording({
|
||||
bool withAudio = true,
|
||||
bool enableDoNotDisturb = true,
|
||||
String? displayName,
|
||||
}) async {
|
||||
final result = await _channel.invokeMapMethod<String, dynamic>(
|
||||
'startRecording',
|
||||
<String, dynamic>{
|
||||
'withAudio': withAudio,
|
||||
'enableDoNotDisturb': enableDoNotDisturb,
|
||||
if (displayName != null) 'displayName': displayName,
|
||||
},
|
||||
);
|
||||
return RecordingStartResult(
|
||||
@@ -97,15 +106,11 @@ class RecordingPlatform {
|
||||
final result = await _channel.invokeMapMethod<String, dynamic>(
|
||||
'stopRecording',
|
||||
);
|
||||
return RecordingStopResult(
|
||||
outputPath: result?['outputPath'] as String?,
|
||||
status: RecordingStatus.fromMap(
|
||||
Map<dynamic, dynamic>.from(result?['status'] as Map? ?? const {}),
|
||||
),
|
||||
);
|
||||
return RecordingStopResult.fromMap(result);
|
||||
}
|
||||
|
||||
static Future<void> disposePreview() => _channel.invokeMethod('disposePreview');
|
||||
static Future<void> disposePreview() =>
|
||||
_channel.invokeMethod('disposePreview');
|
||||
|
||||
static Future<bool> hasNotificationPolicyAccess() async {
|
||||
return await _channel.invokeMethod<bool>('hasNotificationPolicyAccess') ??
|
||||
@@ -136,10 +141,9 @@ class RecordingPlatform {
|
||||
}
|
||||
|
||||
static Future<void> setImmersiveMode({required bool enabled}) {
|
||||
return _channel.invokeMethod(
|
||||
'setImmersiveMode',
|
||||
<String, dynamic>{'enabled': enabled},
|
||||
);
|
||||
return _channel.invokeMethod('setImmersiveMode', <String, dynamic>{
|
||||
'enabled': enabled,
|
||||
});
|
||||
}
|
||||
|
||||
static Future<RecordingStatus> getStatus() async {
|
||||
@@ -156,8 +160,26 @@ class RecordingStartResult {
|
||||
}
|
||||
|
||||
class RecordingStopResult {
|
||||
const RecordingStopResult({this.outputPath, required this.status});
|
||||
const RecordingStopResult({
|
||||
this.outputPath,
|
||||
required this.status,
|
||||
this.gallerySaved = true,
|
||||
this.galleryErrorMessage,
|
||||
});
|
||||
|
||||
final String? outputPath;
|
||||
final RecordingStatus status;
|
||||
final bool gallerySaved;
|
||||
final String? galleryErrorMessage;
|
||||
|
||||
factory RecordingStopResult.fromMap(Map<String, dynamic>? result) {
|
||||
return RecordingStopResult(
|
||||
outputPath: result?['outputPath'] as String?,
|
||||
status: RecordingStatus.fromMap(
|
||||
Map<dynamic, dynamic>.from(result?['status'] as Map? ?? const {}),
|
||||
),
|
||||
gallerySaved: result?['gallerySaved'] as bool? ?? true,
|
||||
galleryErrorMessage: result?['galleryErrorMessage'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@ import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_template/features/recording/recording_platform.dart';
|
||||
import 'package:recording_tool/core/permission/permission_service.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/view-model/view_model_recording.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
class RecordingSessionState {
|
||||
@@ -16,8 +19,10 @@ class RecordingSessionState {
|
||||
this.notificationsGranted = true,
|
||||
this.isMicrophoneGranted = false,
|
||||
this.lastOutputPath,
|
||||
this.lastSavedDisplayName,
|
||||
this.errorMessage,
|
||||
this.permissionWarning,
|
||||
this.gallerySaveFailed = false,
|
||||
});
|
||||
|
||||
final RecordingStatus status;
|
||||
@@ -28,8 +33,10 @@ class RecordingSessionState {
|
||||
final bool notificationsGranted;
|
||||
final bool isMicrophoneGranted;
|
||||
final String? lastOutputPath;
|
||||
final String? lastSavedDisplayName;
|
||||
final String? errorMessage;
|
||||
final String? permissionWarning;
|
||||
final bool gallerySaveFailed;
|
||||
|
||||
bool get isRecording => status.isRecording;
|
||||
|
||||
@@ -49,9 +56,12 @@ class RecordingSessionState {
|
||||
bool? notificationsGranted,
|
||||
bool? isMicrophoneGranted,
|
||||
String? lastOutputPath,
|
||||
String? lastSavedDisplayName,
|
||||
String? errorMessage,
|
||||
String? permissionWarning,
|
||||
bool? gallerySaveFailed,
|
||||
bool clearPermissionWarning = false,
|
||||
bool clearLastSaved = false,
|
||||
}) {
|
||||
return RecordingSessionState(
|
||||
status: status ?? this.status,
|
||||
@@ -63,18 +73,22 @@ class RecordingSessionState {
|
||||
notificationsGranted: notificationsGranted ?? this.notificationsGranted,
|
||||
isMicrophoneGranted: isMicrophoneGranted ?? this.isMicrophoneGranted,
|
||||
lastOutputPath: lastOutputPath ?? this.lastOutputPath,
|
||||
lastSavedDisplayName: clearLastSaved
|
||||
? null
|
||||
: (lastSavedDisplayName ?? this.lastSavedDisplayName),
|
||||
errorMessage: errorMessage,
|
||||
permissionWarning: clearPermissionWarning
|
||||
? null
|
||||
: (permissionWarning ?? this.permissionWarning),
|
||||
gallerySaveFailed: gallerySaveFailed ?? this.gallerySaveFailed,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final recordingSessionControllerProvider =
|
||||
NotifierProvider<RecordingSessionController, RecordingSessionState>(
|
||||
RecordingSessionController.new,
|
||||
);
|
||||
RecordingSessionController.new,
|
||||
);
|
||||
|
||||
class RecordingSessionController extends Notifier<RecordingSessionState> {
|
||||
StreamSubscription<RecordingStatus>? _statusSubscription;
|
||||
@@ -87,15 +101,16 @@ class RecordingSessionController extends Notifier<RecordingSessionState> {
|
||||
|
||||
Future<void> prepareSession() async {
|
||||
if (!RecordingPlatform.isSupported) {
|
||||
state = state.copyWith(errorMessage: '仅支持 Android 录制');
|
||||
state = state.copyWith(errorMessage: '当前设备不支持录制');
|
||||
return;
|
||||
}
|
||||
|
||||
final permissions = await <Permission>[
|
||||
final permissions = await PermissionService.requestMissing([
|
||||
Permission.camera,
|
||||
Permission.microphone,
|
||||
if (Platform.isAndroid) Permission.notification,
|
||||
].request();
|
||||
..._galleryPermissions(),
|
||||
]);
|
||||
|
||||
final cameraGranted = permissions[Permission.camera]?.isGranted ?? false;
|
||||
if (!cameraGranted) {
|
||||
@@ -116,6 +131,9 @@ class RecordingSessionController extends Notifier<RecordingSessionState> {
|
||||
if (!microphoneGranted) {
|
||||
warnings.add('未授予麦克风权限,当前将以静音模式录制');
|
||||
}
|
||||
if (!_isGalleryPermissionGranted(permissions)) {
|
||||
warnings.add('未授予相册权限,录制结束后可能无法保存到相册');
|
||||
}
|
||||
|
||||
final hasDnd = await RecordingPlatform.hasNotificationPolicyAccess();
|
||||
final batteryIgnored =
|
||||
@@ -160,26 +178,49 @@ class RecordingSessionController extends Notifier<RecordingSessionState> {
|
||||
if (!shouldRetry) {
|
||||
rethrow;
|
||||
}
|
||||
await Future<void>.delayed(
|
||||
Duration(milliseconds: 150 * (attempt + 1)),
|
||||
);
|
||||
await Future<void>.delayed(Duration(milliseconds: 150 * (attempt + 1)));
|
||||
}
|
||||
}
|
||||
throw StateError('initializePreview retry exhausted');
|
||||
}
|
||||
|
||||
List<Permission> _galleryPermissions() {
|
||||
if (Platform.isIOS) {
|
||||
return [Permission.photosAddOnly, Permission.photos];
|
||||
}
|
||||
if (Platform.isAndroid) {
|
||||
return [Permission.videos, Permission.storage];
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
|
||||
bool _isGalleryPermissionGranted(Map<Permission, PermissionStatus> permissions) {
|
||||
for (final permission in _galleryPermissions()) {
|
||||
if (permissions[permission]?.isGranted ?? false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return _galleryPermissions().isEmpty;
|
||||
}
|
||||
|
||||
Future<void> startRecording({bool enableDoNotDisturb = true}) async {
|
||||
if (!state.isPreviewReady || state.isRecording) return;
|
||||
|
||||
final clipboard = ref.read(recordingViewModelProvider).clipboardRecordingModel;
|
||||
final displayName = recordingFileNameForPlatform(clipboard.filename);
|
||||
|
||||
try {
|
||||
final result = await RecordingPlatform.startRecording(
|
||||
enableDoNotDisturb: enableDoNotDisturb && state.hasDndAccess,
|
||||
displayName: displayName,
|
||||
);
|
||||
state = state.copyWith(
|
||||
status: result.status,
|
||||
lastOutputPath: result.outputPath,
|
||||
isTouchLocked: true,
|
||||
errorMessage: null,
|
||||
gallerySaveFailed: false,
|
||||
clearLastSaved: true,
|
||||
);
|
||||
} on PlatformException catch (error) {
|
||||
state = state.copyWith(errorMessage: error.message ?? '开始录制失败');
|
||||
@@ -191,10 +232,18 @@ class RecordingSessionController extends Notifier<RecordingSessionState> {
|
||||
|
||||
try {
|
||||
final result = await RecordingPlatform.stopRecording();
|
||||
final galleryFailed = !result.gallerySaved;
|
||||
final savedName = recordingFileNameForPlatform(
|
||||
ref.read(recordingViewModelProvider).clipboardRecordingModel.filename,
|
||||
);
|
||||
state = state.copyWith(
|
||||
status: result.status,
|
||||
lastOutputPath: result.outputPath ?? state.lastOutputPath,
|
||||
errorMessage: null,
|
||||
lastSavedDisplayName: galleryFailed ? null : savedName,
|
||||
errorMessage: galleryFailed
|
||||
? (result.galleryErrorMessage ?? '保存到相册失败,请开启相册权限')
|
||||
: null,
|
||||
gallerySaveFailed: galleryFailed,
|
||||
);
|
||||
} on PlatformException catch (error) {
|
||||
state = state.copyWith(errorMessage: error.message ?? '停止录制失败');
|
||||
|
||||
98
lib/features/recording/view-model/view_model_recording.dart
Normal file
@@ -0,0 +1,98 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_riverpod/legacy.dart';
|
||||
import 'package:recording_tool/core/logging/app_logger.dart';
|
||||
import 'package:recording_tool/features/recording/model/model_clipboard.dart';
|
||||
import 'package:recording_tool/features/recording/model/model_recording.dart';
|
||||
|
||||
final recordingViewModelProvider =
|
||||
StateNotifierProvider<RecordingViewModel, RecordingModel>((ref) {
|
||||
return RecordingViewModel(ref);
|
||||
});
|
||||
|
||||
/// 剪切板读取结果,供 UI 决定是否提示用户。
|
||||
enum ClipboardReadResult {
|
||||
/// 剪切板为空,不提示
|
||||
empty,
|
||||
|
||||
/// 解析成功
|
||||
success,
|
||||
|
||||
/// 有内容但格式不符合小程序录制信息
|
||||
invalid,
|
||||
}
|
||||
|
||||
class RecordingViewModel extends StateNotifier<RecordingModel> {
|
||||
RecordingViewModel(this.ref)
|
||||
: super(
|
||||
RecordingModel(
|
||||
clipboardRecordingModel: ClipboardRecordingModel(
|
||||
title: '',
|
||||
startTimestamp: 0,
|
||||
endTimestamp: 0,
|
||||
address: '',
|
||||
),
|
||||
),
|
||||
);
|
||||
final Ref ref;
|
||||
|
||||
static final _defaultClipboard = ClipboardRecordingModel(
|
||||
title: '',
|
||||
startTimestamp: 0,
|
||||
endTimestamp: 0,
|
||||
address: '',
|
||||
);
|
||||
|
||||
/// 从剪切板获取小程序复制的录制信息。
|
||||
Future<ClipboardReadResult> getClipboardContent() async {
|
||||
try {
|
||||
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
|
||||
final text = clipboardData?.text;
|
||||
AppLogger.debug('读取剪切板内容:$text');
|
||||
|
||||
if (text == null || text.trim().isEmpty) {
|
||||
AppLogger.info('剪切板内容为空,跳过录制信息解析');
|
||||
_resetClipboardInfo();
|
||||
return ClipboardReadResult.empty;
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(text.trim());
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
AppLogger.warning('剪切板内容不是 JSON 对象,跳过录制信息解析');
|
||||
_resetClipboardInfo();
|
||||
return ClipboardReadResult.invalid;
|
||||
}
|
||||
|
||||
final clipboardRecordingModel = ClipboardRecordingModel.fromJson(decoded);
|
||||
if (clipboardRecordingModel.title.trim().isEmpty) {
|
||||
AppLogger.warning('剪切板录制信息缺少有效 title');
|
||||
_resetClipboardInfo();
|
||||
return ClipboardReadResult.invalid;
|
||||
}
|
||||
|
||||
state = state.copyWith(
|
||||
clipboardRecordingModel: clipboardRecordingModel,
|
||||
hasValidClipboardInfo: true,
|
||||
);
|
||||
AppLogger.info('剪切板录制信息解析成功:${clipboardRecordingModel.toJson()}');
|
||||
return ClipboardReadResult.success;
|
||||
} on FormatException catch (error) {
|
||||
AppLogger.warning('剪切板录制信息格式错误:$error');
|
||||
_resetClipboardInfo();
|
||||
return ClipboardReadResult.invalid;
|
||||
} catch (error, stackTrace) {
|
||||
AppLogger.debug('读取剪切板录制信息失败', error: error, stackTrace: stackTrace);
|
||||
_resetClipboardInfo();
|
||||
return ClipboardReadResult.invalid;
|
||||
}
|
||||
}
|
||||
|
||||
void _resetClipboardInfo() {
|
||||
state = state.copyWith(
|
||||
clipboardRecordingModel: _defaultClipboard,
|
||||
hasValidClipboardInfo: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,18 +8,27 @@ class CameraPreviewWidget extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!Platform.isAndroid) {
|
||||
return const ColoredBox(
|
||||
color: Colors.black,
|
||||
child: Center(child: Text('仅 Android 支持相机预览')),
|
||||
if (Platform.isAndroid) {
|
||||
return AndroidView(
|
||||
viewType: 'recording-camera-preview',
|
||||
layoutDirection: TextDirection.ltr,
|
||||
creationParams: const <String, dynamic>{},
|
||||
creationParamsCodec: const StandardMessageCodec(),
|
||||
);
|
||||
}
|
||||
|
||||
return AndroidView(
|
||||
viewType: 'recording-camera-preview',
|
||||
layoutDirection: TextDirection.ltr,
|
||||
creationParams: const <String, dynamic>{},
|
||||
creationParamsCodec: const StandardMessageCodec(),
|
||||
if (Platform.isIOS) {
|
||||
return UiKitView(
|
||||
viewType: 'recording-camera-preview',
|
||||
layoutDirection: TextDirection.ltr,
|
||||
creationParams: const <String, dynamic>{},
|
||||
creationParamsCodec: const StandardMessageCodec(),
|
||||
);
|
||||
}
|
||||
|
||||
return const ColoredBox(
|
||||
color: Colors.black,
|
||||
child: Center(child: Text('当前平台不支持相机预览')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import 'package:flutter_template/app/bootstrap.dart';
|
||||
import 'package:recording_tool/app/bootstrap.dart';
|
||||
|
||||
Future<void> main() => AppBootstrapper.bootstrap();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_template/shared/widgets/app_network_image.dart';
|
||||
import 'package:recording_tool/shared/widgets/app_network_image.dart';
|
||||
|
||||
class AppAvatar extends StatelessWidget {
|
||||
const AppAvatar({super.key, this.imageUrl, this.initials, this.size = 40});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_template/app/theme/app_theme.dart';
|
||||
import 'package:recording_tool/app/theme/app_theme.dart';
|
||||
|
||||
class AppCard extends StatelessWidget {
|
||||
const AppCard({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_template/shared/widgets/app_button.dart';
|
||||
import 'package:recording_tool/shared/widgets/app_button.dart';
|
||||
|
||||
class AppErrorView extends StatelessWidget {
|
||||
const AppErrorView({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_template/core/utils/rate_limiter.dart';
|
||||
import 'package:recording_tool/core/utils/rate_limiter.dart';
|
||||
|
||||
class AppSearchBar extends StatefulWidget {
|
||||
const AppSearchBar({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_template/shared/widgets/app_empty_view.dart';
|
||||
import 'package:flutter_template/shared/widgets/app_error_view.dart';
|
||||
import 'package:flutter_template/shared/widgets/app_loading_view.dart';
|
||||
import 'package:recording_tool/shared/widgets/app_empty_view.dart';
|
||||
import 'package:recording_tool/shared/widgets/app_error_view.dart';
|
||||
import 'package:recording_tool/shared/widgets/app_loading_view.dart';
|
||||
|
||||
enum AppViewStatus { loading, empty, error, content }
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_template/app/router/app_navigator.dart';
|
||||
import 'package:recording_tool/app/router/app_navigator.dart';
|
||||
|
||||
class AppToast {
|
||||
AppToast._();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
name: flutter_template
|
||||
description: "A reusable Flutter quick-start template for Android and iOS."
|
||||
name: recording_tool
|
||||
description: "A recording tool for Android and iOS."
|
||||
# The following line prevents the package from being accidentally published to
|
||||
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
||||
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
||||
|
||||
# The following defines the version and build number for your application.
|
||||
# A version number is three numbers separated by dots, like 1.2.43
|
||||
@@ -66,7 +66,6 @@ dev_dependencies:
|
||||
|
||||
# The following section is specific to Flutter packages.
|
||||
flutter:
|
||||
|
||||
# The following line ensures that the Material Icons font is
|
||||
# included with your application, so that you can use the icons in
|
||||
# the material Icons class.
|
||||
|
||||
131
test/core/permission/permission_service_test.dart
Normal file
@@ -0,0 +1,131 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
// ignore: depend_on_referenced_packages
|
||||
import 'package:permission_handler_platform_interface/permission_handler_platform_interface.dart';
|
||||
|
||||
import 'package:recording_tool/core/permission/permission_service.dart';
|
||||
|
||||
void main() {
|
||||
group('PermissionService.requestMissing', () {
|
||||
late PermissionHandlerPlatform originalPlatform;
|
||||
|
||||
setUp(() {
|
||||
originalPlatform = PermissionHandlerPlatform.instance;
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
PermissionHandlerPlatform.instance = originalPlatform;
|
||||
});
|
||||
|
||||
test('requests only missing permissions and skips granted ones', () async {
|
||||
final platform = FakePermissionHandlerPlatform(
|
||||
statuses: <Permission, PermissionStatus>{
|
||||
Permission.camera: PermissionStatus.granted,
|
||||
Permission.microphone: PermissionStatus.denied,
|
||||
},
|
||||
requestResults: <Permission, PermissionStatus>{
|
||||
Permission.microphone: PermissionStatus.granted,
|
||||
},
|
||||
);
|
||||
PermissionHandlerPlatform.instance = platform;
|
||||
|
||||
final result = await PermissionService.requestMissing(<Permission>[
|
||||
Permission.camera,
|
||||
Permission.microphone,
|
||||
]);
|
||||
|
||||
expect(platform.requestCalls, <List<Permission>>[
|
||||
<Permission>[Permission.microphone],
|
||||
]);
|
||||
expect(result[Permission.camera], PermissionStatus.granted);
|
||||
expect(result[Permission.microphone], PermissionStatus.granted);
|
||||
});
|
||||
|
||||
test(
|
||||
'preserves permanently denied permissions without requesting them',
|
||||
() async {
|
||||
final platform = FakePermissionHandlerPlatform(
|
||||
statuses: <Permission, PermissionStatus>{
|
||||
Permission.camera: PermissionStatus.permanentlyDenied,
|
||||
Permission.microphone: PermissionStatus.denied,
|
||||
},
|
||||
requestResults: <Permission, PermissionStatus>{
|
||||
Permission.microphone: PermissionStatus.granted,
|
||||
},
|
||||
);
|
||||
PermissionHandlerPlatform.instance = platform;
|
||||
|
||||
final result = await PermissionService.requestMissing(<Permission>[
|
||||
Permission.camera,
|
||||
Permission.microphone,
|
||||
]);
|
||||
|
||||
expect(platform.requestCalls, <List<Permission>>[
|
||||
<Permission>[Permission.microphone],
|
||||
]);
|
||||
expect(result[Permission.camera], PermissionStatus.permanentlyDenied);
|
||||
expect(result[Permission.microphone], PermissionStatus.granted);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('iOS permission configuration', () {
|
||||
test('Podfile enables camera, microphone and photos permission macros', () {
|
||||
final podfile = File('ios/Podfile').readAsStringSync();
|
||||
|
||||
expect(
|
||||
podfile,
|
||||
contains('flutter_additional_ios_build_settings(target)'),
|
||||
);
|
||||
expect(podfile, contains("'PERMISSION_CAMERA=1'"));
|
||||
expect(podfile, contains("'PERMISSION_MICROPHONE=1'"));
|
||||
expect(podfile, contains("'PERMISSION_PHOTOS=1'"));
|
||||
expect(podfile, contains("'PERMISSION_PHOTOS_ADD_ONLY=1'"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class FakePermissionHandlerPlatform extends PermissionHandlerPlatform {
|
||||
FakePermissionHandlerPlatform({
|
||||
required this.statuses,
|
||||
required this.requestResults,
|
||||
});
|
||||
|
||||
final Map<Permission, PermissionStatus> statuses;
|
||||
final Map<Permission, PermissionStatus> requestResults;
|
||||
final List<List<Permission>> requestCalls = <List<Permission>>[];
|
||||
|
||||
@override
|
||||
Future<PermissionStatus> checkPermissionStatus(Permission permission) async {
|
||||
return statuses[permission] ?? PermissionStatus.denied;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ServiceStatus> checkServiceStatus(Permission permission) async {
|
||||
return ServiceStatus.enabled;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> openAppSettings() async {
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<Permission, PermissionStatus>> requestPermissions(
|
||||
List<Permission> permissions,
|
||||
) async {
|
||||
requestCalls.add(List<Permission>.unmodifiable(permissions));
|
||||
return <Permission, PermissionStatus>{
|
||||
for (final permission in permissions)
|
||||
permission: requestResults[permission] ?? PermissionStatus.granted,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> shouldShowRequestPermissionRationale(
|
||||
Permission permission,
|
||||
) async {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
53
test/features/recording/model_clipboard_test.dart
Normal file
@@ -0,0 +1,53 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:recording_tool/features/recording/model/model_clipboard.dart';
|
||||
|
||||
void main() {
|
||||
group('ClipboardRecordingModel', () {
|
||||
const clipboardJson = {
|
||||
'title': '王东方 丨李想 空中格斗赛',
|
||||
'startTimestamp': 1717334400,
|
||||
'endTimestamp': 1717334400,
|
||||
'address': '广州市番禺区·粤港澳大湾区青年人才双创小镇',
|
||||
};
|
||||
|
||||
test('parses mini program clipboard JSON', () {
|
||||
final model = ClipboardRecordingModel.fromJson(clipboardJson);
|
||||
|
||||
expect(model.title, '王东方 丨李想 空中格斗赛');
|
||||
expect(model.startTimestamp, 1717334400);
|
||||
expect(model.endTimestamp, 1717334400);
|
||||
expect(model.address, '广州市番禺区·粤港澳大湾区青年人才双创小镇');
|
||||
expect(model.filename, isNull);
|
||||
expect(model.toJson(), clipboardJson);
|
||||
});
|
||||
|
||||
test('parses optional filename from mini program JSON', () {
|
||||
final json = {
|
||||
...clipboardJson,
|
||||
'filename': '选手名称_选手ID_赛事名称_赛项',
|
||||
};
|
||||
final model = ClipboardRecordingModel.fromJson(json);
|
||||
|
||||
expect(model.filename, '选手名称_选手ID_赛事名称_赛项');
|
||||
expect(model.toJson(), json);
|
||||
});
|
||||
|
||||
test('throws FormatException when required field is missing', () {
|
||||
final json = Map<String, dynamic>.from(clipboardJson)..remove('title');
|
||||
|
||||
expect(
|
||||
() => ClipboardRecordingModel.fromJson(json),
|
||||
throwsA(isA<FormatException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('throws FormatException when required field has wrong type', () {
|
||||
final json = {...clipboardJson, 'startTimestamp': '1717334400'};
|
||||
|
||||
expect(
|
||||
() => ClipboardRecordingModel.fromJson(json),
|
||||
throwsA(isA<FormatException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
69
test/features/recording/recording_display_name_test.dart
Normal file
@@ -0,0 +1,69 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:recording_tool/features/recording/recording_display_name.dart';
|
||||
|
||||
void main() {
|
||||
group('sanitizeRecordingBaseName', () {
|
||||
test('removes invalid path characters', () {
|
||||
expect(
|
||||
sanitizeRecordingBaseName(r'a/b:c*d?e"f<g>h|i'),
|
||||
'a_b_c_d_e_f_g_h_i',
|
||||
);
|
||||
});
|
||||
|
||||
test('returns null for blank input', () {
|
||||
expect(sanitizeRecordingBaseName(' '), isNull);
|
||||
});
|
||||
|
||||
test('truncates overly long names', () {
|
||||
final long = 'a' * 200;
|
||||
expect(sanitizeRecordingBaseName(long)!.length, 120);
|
||||
});
|
||||
});
|
||||
|
||||
group('resolveRecordingDisplayName', () {
|
||||
test('uses sanitized clipboard filename when present', () {
|
||||
expect(
|
||||
resolveRecordingDisplayName('选手名称_选手ID_赛事名称_赛项'),
|
||||
'选手名称_选手ID_赛事名称_赛项',
|
||||
);
|
||||
});
|
||||
|
||||
test('falls back to REC_ prefix when clipboard filename is empty', () {
|
||||
expect(resolveRecordingDisplayName(null), startsWith('REC_'));
|
||||
expect(resolveRecordingDisplayName(''), startsWith('REC_'));
|
||||
});
|
||||
});
|
||||
|
||||
group('withVideoExtension', () {
|
||||
test('appends mp4 on Android', () {
|
||||
expect(
|
||||
withVideoExtension('match', isIOS: false),
|
||||
'match.mp4',
|
||||
);
|
||||
});
|
||||
|
||||
test('appends mov on iOS', () {
|
||||
expect(
|
||||
withVideoExtension('match', isIOS: true),
|
||||
'match.mov',
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps existing extension', () {
|
||||
expect(withVideoExtension('a.mp4', isIOS: false), 'a.mp4');
|
||||
expect(withVideoExtension('a.MOV', isIOS: true), 'a.MOV');
|
||||
});
|
||||
});
|
||||
|
||||
group('recordingFileNameForPlatform', () {
|
||||
test('combines clipboard name with platform extension', () {
|
||||
expect(
|
||||
recordingFileNameForPlatform(
|
||||
'选手名称_选手ID_赛事名称_赛项',
|
||||
isIOS: false,
|
||||
),
|
||||
'选手名称_选手ID_赛事名称_赛项.mp4',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
21
test/features/recording/recording_platform_test.dart
Normal file
@@ -0,0 +1,21 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:recording_tool/features/recording/recording_platform.dart';
|
||||
|
||||
void main() {
|
||||
group('RecordingPlatform support', () {
|
||||
test('supports Android and iOS hosts only', () {
|
||||
expect(
|
||||
RecordingPlatform.supportsHost(isAndroid: true, isIOS: false),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
RecordingPlatform.supportsHost(isAndroid: false, isIOS: true),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
RecordingPlatform.supportsHost(isAndroid: false, isIOS: false),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
160
test/features/recording/view_model_recording_test.dart
Normal file
@@ -0,0 +1,160 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:recording_tool/features/recording/view-model/view_model_recording.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
const defaultClipboardTitle = '';
|
||||
const validClipboardText =
|
||||
'{"title":"王东方 丨李想 空中格斗赛","startTimestamp":1717334400,"endTimestamp":1717334400,"filename":"选手名称_选手ID_赛事名称_赛项","address":"广州市番禺区·粤港澳大湾区青年人才双创小镇"}';
|
||||
|
||||
Future<void> setClipboardText(String? text) async {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(SystemChannels.platform, (call) async {
|
||||
if (call.method == 'Clipboard.getData') {
|
||||
return text == null ? null : <String, dynamic>{'text': text};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
tearDown(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(SystemChannels.platform, null);
|
||||
});
|
||||
|
||||
group('RecordingViewModel.getClipboardContent', () {
|
||||
test(
|
||||
'updates state when clipboard contains valid mini program JSON',
|
||||
() async {
|
||||
await setClipboardText(validClipboardText);
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final result = await container
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.getClipboardContent();
|
||||
|
||||
expect(result, ClipboardReadResult.success);
|
||||
final model = container.read(recordingViewModelProvider);
|
||||
expect(model.hasValidClipboardInfo, isTrue);
|
||||
expect(model.clipboardRecordingModel.title, '王东方 丨李想 空中格斗赛');
|
||||
expect(model.clipboardRecordingModel.startTimestamp, 1717334400);
|
||||
expect(model.clipboardRecordingModel.endTimestamp, 1717334400);
|
||||
expect(
|
||||
model.clipboardRecordingModel.address,
|
||||
'广州市番禺区·粤港澳大湾区青年人才双创小镇',
|
||||
);
|
||||
expect(
|
||||
model.clipboardRecordingModel.filename,
|
||||
'选手名称_选手ID_赛事名称_赛项',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('returns empty when clipboard is empty', () async {
|
||||
await setClipboardText('');
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final result = await container
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.getClipboardContent();
|
||||
|
||||
expect(result, ClipboardReadResult.empty);
|
||||
final model = container.read(recordingViewModelProvider);
|
||||
expect(model.hasValidClipboardInfo, isFalse);
|
||||
expect(model.clipboardRecordingModel.title, defaultClipboardTitle);
|
||||
});
|
||||
|
||||
test('returns invalid when clipboard is not JSON', () async {
|
||||
await setClipboardText('hello');
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final result = await container
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.getClipboardContent();
|
||||
|
||||
expect(result, ClipboardReadResult.invalid);
|
||||
expect(
|
||||
container.read(recordingViewModelProvider).clipboardRecordingModel.title,
|
||||
defaultClipboardTitle,
|
||||
);
|
||||
expect(
|
||||
container.read(recordingViewModelProvider).hasValidClipboardInfo,
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('returns invalid when clipboard JSON is not an object', () async {
|
||||
await setClipboardText('[1,2,3]');
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final result = await container
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.getClipboardContent();
|
||||
|
||||
expect(result, ClipboardReadResult.invalid);
|
||||
expect(
|
||||
container.read(recordingViewModelProvider).clipboardRecordingModel.title,
|
||||
defaultClipboardTitle,
|
||||
);
|
||||
});
|
||||
|
||||
test('returns invalid when clipboard JSON misses required fields', () async {
|
||||
await setClipboardText('{"title":"王东方 丨李想 空中格斗赛"}');
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final result = await container
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.getClipboardContent();
|
||||
|
||||
expect(result, ClipboardReadResult.invalid);
|
||||
expect(
|
||||
container.read(recordingViewModelProvider).clipboardRecordingModel.title,
|
||||
defaultClipboardTitle,
|
||||
);
|
||||
});
|
||||
|
||||
test('returns invalid when clipboard JSON has wrong field type', () async {
|
||||
await setClipboardText(
|
||||
'{"title":"王东方 丨李想 空中格斗赛","startTimestamp":"1717334400","endTimestamp":1717334400,"address":"广州市番禺区·粤港澳大湾区青年人才双创小镇"}',
|
||||
);
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final result = await container
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.getClipboardContent();
|
||||
|
||||
expect(result, ClipboardReadResult.invalid);
|
||||
expect(
|
||||
container.read(recordingViewModelProvider).clipboardRecordingModel.title,
|
||||
defaultClipboardTitle,
|
||||
);
|
||||
});
|
||||
|
||||
test('returns invalid when title is blank', () async {
|
||||
await setClipboardText(
|
||||
'{"title":" ","startTimestamp":1717334400,"endTimestamp":1717334400,"address":"广州市"}',
|
||||
);
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final result = await container
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.getClipboardContent();
|
||||
|
||||
expect(result, ClipboardReadResult.invalid);
|
||||
expect(
|
||||
container.read(recordingViewModelProvider).hasValidClipboardInfo,
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_template/app/app.dart';
|
||||
import 'package:recording_tool/app/app.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('template app renders demo page', (tester) async {
|
||||
testWidgets('recording app renders recording page', (tester) async {
|
||||
await tester.pumpWidget(const ProviderScope(child: FlutterTemplateApp()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Flutter Template'), findsOneWidget);
|
||||
expect(find.text('通用 Flutter 快速开发模板'), findsOneWidget);
|
||||
expect(find.text('增加计数'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.fiber_manual_record), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||