3 Commits

Author SHA1 Message Date
66435302b3 升级 Gradle → 8.14、AGP → 8.11、Kotlin → 2.2.20 JVM 堆降到 -Xmx4G 2026-06-04 13:35:10 +08:00
250f21a2b8 优化 2026-06-04 10:50:24 +08:00
8f9f3a9779 兼容 IOS 端 2026-06-03 23:37:02 +08:00
85 changed files with 2600 additions and 526 deletions

1
.gitignore vendored
View File

@@ -11,6 +11,7 @@
.svn/ .svn/
.swiftpm/ .swiftpm/
migrate_working_dir/ migrate_working_dir/
.vscode
# IntelliJ related # IntelliJ related
*.iml *.iml

View File

@@ -14,7 +14,7 @@ A production-ready Flutter quick-start template extracted from real-world projec
## Tech Stack ## Tech Stack
| Category | Package | Purpose | | Category | Package | Purpose |
|---|---|---| | ----------------- | -------------------- | --------------------------------------- |
| State Management | flutter_riverpod | Compile-safe, testable state management | | State Management | flutter_riverpod | Compile-safe, testable state management |
| Networking | dio | HTTP client with interceptor chain | | Networking | dio | HTTP client with interceptor chain |
| Local Cache | shared_preferences | Key-value persistence | | Local Cache | shared_preferences | Key-value persistence |
@@ -72,7 +72,7 @@ lib/
## Getting Started ## Getting Started
```bash ```bash
cd flutter-template cd record-tool
flutter pub get flutter pub get
flutter analyze flutter analyze
flutter test flutter test

View File

@@ -14,7 +14,7 @@
## 技术栈 ## 技术栈
| 类别 | 依赖 | 用途 | | 类别 | 依赖 | 用途 |
|---|---|---| | -------- | -------------------- | -------------------------- |
| 状态管理 | flutter_riverpod | 编译安全、可测试的状态管理 | | 状态管理 | flutter_riverpod | 编译安全、可测试的状态管理 |
| 网络请求 | dio | HTTP 客户端,支持拦截器链 | | 网络请求 | dio | HTTP 客户端,支持拦截器链 |
| 本地缓存 | shared_preferences | KV 持久化存储 | | 本地缓存 | shared_preferences | KV 持久化存储 |
@@ -71,7 +71,7 @@ lib/
## 快速开始 ## 快速开始
```bash ```bash
cd flutter-template cd record-tool
flutter pub get flutter pub get
flutter analyze flutter analyze
flutter test flutter test

View File

@@ -5,8 +5,10 @@ plugins {
id("dev.flutter.flutter-gradle-plugin") id("dev.flutter.flutter-gradle-plugin")
} }
val appPackageName = "com.gdfw.fxjk"
android { android {
namespace = "com.example.flutter_template" namespace = appPackageName
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion ndkVersion = flutter.ndkVersion
@@ -20,8 +22,7 @@ android {
} }
defaultConfig { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId = appPackageName
applicationId = "com.example.flutter_template"
// You can update the following values to match your application needs. // You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config. // For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion minSdk = flutter.minSdkVersion

View File

@@ -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.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
@@ -8,6 +9,10 @@
<uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY" /> <uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" /> <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 <uses-feature
android:name="android.hardware.camera" android:name="android.hardware.camera"

View File

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

View File

@@ -1,8 +1,8 @@
package com.example.flutter_template package com.gdfw.fxjk
import androidx.camera.view.PreviewView import androidx.camera.view.PreviewView
import com.example.flutter_template.recording.RecordingPlatformHandler import com.gdfw.fxjk.recording.RecordingPlatformHandler
import com.example.flutter_template.recording.RecordingPreviewFactory import com.gdfw.fxjk.recording.RecordingPreviewFactory
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine

View File

@@ -1,4 +1,4 @@
package com.example.flutter_template.recording package com.gdfw.fxjk.recording
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent

View File

@@ -1,4 +1,4 @@
package com.example.flutter_template.recording package com.gdfw.fxjk.recording
import android.app.NotificationManager import android.app.NotificationManager
import android.content.Context import android.content.Context

View File

@@ -1,11 +1,10 @@
package com.example.flutter_template.recording package com.gdfw.fxjk.recording
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log
import androidx.camera.core.CameraSelector import androidx.camera.core.CameraSelector
import androidx.camera.core.Preview import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.video.FileOutputOptions
import androidx.camera.video.Quality import androidx.camera.video.Quality
import androidx.camera.video.QualitySelector import androidx.camera.video.QualitySelector
import androidx.camera.video.Recorder import androidx.camera.video.Recorder
@@ -15,10 +14,6 @@ import androidx.camera.video.VideoRecordEvent
import androidx.camera.view.PreviewView import androidx.camera.view.PreviewView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleOwner 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 import java.util.concurrent.Executor
class RecordingCameraController( class RecordingCameraController(
@@ -39,6 +34,7 @@ class RecordingCameraController(
private var recordingStartedAt: Long = 0L private var recordingStartedAt: Long = 0L
private var latestOutputPath: String? = null private var latestOutputPath: String? = null
private var pendingStopCallback: ((String?) -> Unit)? = null
fun bindPreview( fun bindPreview(
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
@@ -118,6 +114,7 @@ class RecordingCameraController(
fun startRecording( fun startRecording(
withAudio: Boolean, withAudio: Boolean,
displayName: String?,
onStarted: (Boolean, String?) -> Unit, onStarted: (Boolean, String?) -> Unit,
) { ) {
val capture = videoCapture val capture = videoCapture
@@ -131,9 +128,12 @@ class RecordingCameraController(
return return
} }
val outputFile = createOutputFile() val outputOptions =
latestOutputPath = outputFile.absolutePath RecordingOutputFactory.buildMediaStoreOutputOptions(
val outputOptions = FileOutputOptions.Builder(outputFile).build() appContext,
displayName,
)
latestOutputPath = null
val pending = val pending =
capture.output.prepareRecording(appContext, outputOptions).apply { capture.output.prepareRecording(appContext, outputOptions).apply {
@@ -171,6 +171,7 @@ class RecordingCameraController(
), ),
) )
} else { } else {
latestOutputPath = event.outputResults.outputUri.toString()
updateStatus( updateStatus(
RecordingStatus( RecordingStatus(
RecordingState.PREVIEWING, 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) { fun stopRecording(onStopped: (String?) -> Unit) {
@@ -193,6 +197,7 @@ class RecordingCameraController(
return return
} }
pendingStopCallback = onStopped
updateStatus( updateStatus(
RecordingStatus( RecordingStatus(
RecordingState.STOPPING, RecordingState.STOPPING,
@@ -202,7 +207,6 @@ class RecordingCameraController(
recording.stop() recording.stop()
activeRecording = null activeRecording = null
onStopped(latestOutputPath)
} }
fun unbind() { fun unbind() {
@@ -221,16 +225,6 @@ class RecordingCameraController(
return System.currentTimeMillis() - recordingStartedAt 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) { private fun updateStatus(next: RecordingStatus) {
status = next status = next
statusListener?.invoke(next) statusListener?.invoke(next)

View File

@@ -1,4 +1,4 @@
package com.example.flutter_template.recording package com.gdfw.fxjk.recording
import android.app.Notification import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
@@ -15,7 +15,8 @@ import android.os.IBinder
import android.os.PowerManager import android.os.PowerManager
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.lifecycle.LifecycleService import androidx.lifecycle.LifecycleService
import com.example.flutter_template.MainActivity import com.gdfw.fxjk.AppConstants
import com.gdfw.fxjk.MainActivity
class RecordingForegroundService : LifecycleService() { class RecordingForegroundService : LifecycleService() {
private var wakeLock: PowerManager.WakeLock? = null private var wakeLock: PowerManager.WakeLock? = null
@@ -29,7 +30,7 @@ class RecordingForegroundService : LifecycleService() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId) super.onStartCommand(intent, flags, startId)
when (intent?.action) { when (intent?.action) {
ACTION_START -> { AppConstants.RECORDING_ACTION_START -> {
acquireWakeLock() acquireWakeLock()
val notification = buildNotification("正在录制") val notification = buildNotification("正在录制")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
@@ -43,7 +44,7 @@ class RecordingForegroundService : LifecycleService() {
} }
isRunning = true isRunning = true
} }
ACTION_STOP -> { AppConstants.RECORDING_ACTION_STOP -> {
releaseWakeLock() releaseWakeLock()
stopForeground(STOP_FOREGROUND_REMOVE) stopForeground(STOP_FOREGROUND_REMOVE)
isRunning = false isRunning = false
@@ -143,8 +144,6 @@ class RecordingForegroundService : LifecycleService() {
companion object { companion object {
const val CHANNEL_ID = "recording_foreground" const val CHANNEL_ID = "recording_foreground"
const val NOTIFICATION_ID = 1001 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" private const val WAKE_LOCK_TAG = "record_tool:recording_wake_lock"
@Volatile @Volatile
@@ -156,7 +155,7 @@ class RecordingForegroundService : LifecycleService() {
fun start(context: Context) { fun start(context: Context) {
val intent = val intent =
Intent(context, RecordingForegroundService::class.java).apply { Intent(context, RecordingForegroundService::class.java).apply {
action = ACTION_START action = AppConstants.RECORDING_ACTION_START
} }
ContextCompatStart.startForegroundService(context, intent) ContextCompatStart.startForegroundService(context, intent)
} }
@@ -164,7 +163,7 @@ class RecordingForegroundService : LifecycleService() {
fun stop(context: Context) { fun stop(context: Context) {
val intent = val intent =
Intent(context, RecordingForegroundService::class.java).apply { Intent(context, RecordingForegroundService::class.java).apply {
action = ACTION_STOP action = AppConstants.RECORDING_ACTION_STOP
} }
context.startService(intent) context.startService(intent)
} }

View File

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

View File

@@ -1,4 +1,4 @@
package com.example.flutter_template.recording package com.gdfw.fxjk.recording
import android.app.Activity import android.app.Activity
import android.os.Build import android.os.Build
@@ -7,7 +7,8 @@ import android.os.Looper
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.WindowInsetsControllerCompat
import com.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.BinaryMessenger
import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
@@ -18,9 +19,9 @@ class RecordingPlatformHandler(
messenger: BinaryMessenger, messenger: BinaryMessenger,
) : MethodChannel.MethodCallHandler, EventChannel.StreamHandler { ) : MethodChannel.MethodCallHandler, EventChannel.StreamHandler {
private val methodChannel = private val methodChannel =
MethodChannel(messenger, "com.example.flutter_template/recording") MethodChannel(messenger, AppConstants.RECORDING_METHOD_CHANNEL)
private val eventChannel = private val eventChannel =
EventChannel(messenger, "com.example.flutter_template/recording_events") EventChannel(messenger, AppConstants.RECORDING_EVENT_CHANNEL)
private val mainHandler = Handler(Looper.getMainLooper()) private val mainHandler = Handler(Looper.getMainLooper())
private var eventSink: EventChannel.EventSink? = null private var eventSink: EventChannel.EventSink? = null
@@ -51,7 +52,8 @@ class RecordingPlatformHandler(
"startRecording" -> { "startRecording" -> {
val withAudio = call.argument<Boolean>("withAudio") ?: true val withAudio = call.argument<Boolean>("withAudio") ?: true
val enableDnd = call.argument<Boolean>("enableDoNotDisturb") ?: 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) "stopRecording" -> stopRecording(result)
"disposePreview" -> { "disposePreview" -> {
@@ -109,6 +111,7 @@ class RecordingPlatformHandler(
private fun startRecording( private fun startRecording(
withAudio: Boolean, withAudio: Boolean,
enableDnd: Boolean, enableDnd: Boolean,
displayName: String?,
result: MethodChannel.Result, result: MethodChannel.Result,
) { ) {
val previewView = activity.recordingPreviewView val previewView = activity.recordingPreviewView
@@ -124,7 +127,7 @@ class RecordingPlatformHandler(
DoNotDisturbHelper.enable(activity) DoNotDisturbHelper.enable(activity)
} }
controller.startRecording(withAudio) { started, message -> controller.startRecording(withAudio, displayName) { started, message ->
mainHandler.post { mainHandler.post {
if (started) { if (started) {
startElapsedTicker() startElapsedTicker()
@@ -169,12 +172,19 @@ class RecordingPlatformHandler(
RecordingSession.stopForeground(activity) RecordingSession.stopForeground(activity)
DoNotDisturbHelper.disable(activity) DoNotDisturbHelper.disable(activity)
mainHandler.post { mainHandler.post {
result.success( val gallerySaved =
mapOf( path != null &&
controller.status.state != RecordingState.ERROR
val payload = mutableMapOf<String, Any?>(
"outputPath" to path, "outputPath" to path,
"status" to controller.status.toMap(), "status" to controller.status.toMap(),
), "gallerySaved" to gallerySaved,
) )
if (!gallerySaved) {
payload["galleryErrorMessage"] =
controller.status.message ?: "保存到相册失败"
}
result.success(payload)
} }
} }
} }

View File

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

View File

@@ -1,4 +1,4 @@
package com.example.flutter_template.recording package com.gdfw.fxjk.recording
import android.content.Context import android.content.Context
import androidx.lifecycle.LifecycleService import androidx.lifecycle.LifecycleService

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

File diff suppressed because one or more lines are too long

View 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.useAndroidX=true
android.enableJetifier=true android.enableJetifier=true
# This builtInKotlin flag was added automatically by Flutter migrator # This builtInKotlin flag was added automatically by Flutter migrator

View File

@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists 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

View File

@@ -19,8 +19,8 @@ pluginManagement {
plugins { plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.9.1" apply false id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false id("org.jetbrains.kotlin.android") version "2.2.20" apply false
} }
include(":app") include(":app")

View File

@@ -20,7 +20,5 @@
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>1.0</string> <string>1.0</string>
<key>MinimumOSVersion</key>
<string>13.0</string>
</dict> </dict>
</plist> </plist>

View File

@@ -39,5 +39,15 @@ end
post_install do |installer| post_install do |installer|
installer.pods_project.targets.each do |target| installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(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
end end

View File

@@ -1,67 +1,22 @@
PODS: PODS:
- connectivity_plus (0.0.1):
- Flutter
- device_info_plus (0.0.1):
- Flutter
- Flutter (1.0.0) - 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): - permission_handler_apple (9.3.0):
- Flutter - Flutter
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- sqflite_darwin (0.0.4):
- Flutter
- FlutterMacOS
- url_launcher_ios (0.0.1):
- Flutter
DEPENDENCIES: DEPENDENCIES:
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- Flutter (from `Flutter`) - 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`) - 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: EXTERNAL SOURCES:
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
Flutter: Flutter:
:path: 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: permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios" :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: SPEC CHECKSUMS:
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d 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 COCOAPODS: 1.16.2

View File

@@ -12,6 +12,8 @@
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
64BC3B3C75FABAA128C4FE7A /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D84231833EC084C45F76D9A0 /* Pods_RunnerTests.framework */; }; 64BC3B3C75FABAA128C4FE7A /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D84231833EC084C45F76D9A0 /* Pods_RunnerTests.framework */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 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 */; }; 87DA76CA5231F693C9392B80 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B6AA1C86CE710449CC3E8FCD /* Pods_Runner.framework */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
87DA76CA5231F693C9392B80 /* Pods_Runner.framework in Frameworks */, 87DA76CA5231F693C9392B80 /* Pods_Runner.framework in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@@ -121,6 +126,7 @@
9740EEB11CF90186004384FC /* Flutter */ = { 9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */, 9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
@@ -160,6 +166,7 @@
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
8D5D9D9D2C69000100F10001 /* RecordingPlugin.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
); );
path = Runner; path = Runner;
@@ -198,7 +205,6 @@
97C146EC1CF9000F007C117D /* Resources */, 97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */, 9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */,
CD126DA202245CE8BB749787 /* [CP] Embed Pods Frameworks */,
62D3774184B6C76B8E34C8C2 /* [CP] Copy Pods Resources */, 62D3774184B6C76B8E34C8C2 /* [CP] Copy Pods Resources */,
); );
buildRules = ( buildRules = (
@@ -206,6 +212,9 @@
dependencies = ( dependencies = (
); );
name = Runner; name = Runner;
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
productName = Runner; productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application"; productType = "com.apple.product-type.application";
@@ -239,6 +248,9 @@
Base, Base,
); );
mainGroup = 97C146E51CF9000F007C117D; mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
);
productRefGroup = 97C146EF1CF9000F007C117D /* Products */; productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = ""; projectDirPath = "";
projectRoot = ""; projectRoot = "";
@@ -363,23 +375,6 @@
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; 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 */ /* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
@@ -396,6 +391,7 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
8D5D9D9E2C69000100F10001 /* RecordingPlugin.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@@ -744,6 +740,20 @@
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
/* End XCConfigurationList section */ /* 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 */; rootObject = 97C146E61CF9000F007C117D /* Project object */;
} }

View File

@@ -5,6 +5,24 @@
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"> buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Prepare Flutter Framework Script"
scriptText = "/bin/sh &quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
</PreActions>
<BuildActionEntries> <BuildActionEntries>
<BuildActionEntry <BuildActionEntry
buildForTesting = "YES" buildForTesting = "YES"

View File

@@ -2,12 +2,18 @@ import Flutter
import UIKit import UIKit
@main @main
@objc class AppDelegate: FlutterAppDelegate { @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application( override func application(
_ application: UIApplication, _ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool { ) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions) 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)
}
}
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 622 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 945 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

View File

@@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
@@ -24,6 +26,35 @@
<string>$(FLUTTER_BUILD_NUMBER)</string> <string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <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> <key>UILaunchStoryboardName</key>
<string>LaunchScreen</string> <string>LaunchScreen</string>
<key>UIMainStoryboardFile</key> <key>UIMainStoryboardFile</key>
@@ -41,9 +72,5 @@
<string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
</dict> </dict>
</plist> </plist>

View 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
}
}

View File

@@ -1,16 +1,46 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_easyloading/flutter_easyloading.dart'; import 'package:flutter_easyloading/flutter_easyloading.dart';
import 'package:flutter_localizations/flutter_localizations.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_screenutil/flutter_screenutil.dart';
import 'package:flutter_template/app/config/app_config.dart'; import 'package:recording_tool/app/config/app_config.dart';
import 'package:flutter_template/app/router/app_navigator.dart'; import 'package:recording_tool/app/router/app_navigator.dart';
import 'package:flutter_template/app/theme/app_theme.dart'; import 'package:recording_tool/app/theme/app_theme.dart';
import 'package:flutter_template/features/demo/demo_page.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'; import 'package:pull_to_refresh/pull_to_refresh.dart';
class FlutterTemplateApp extends StatelessWidget { class FlutterTemplateApp extends ConsumerStatefulWidget {
const FlutterTemplateApp({super.key}); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ScreenUtilInit( return ScreenUtilInit(
@@ -44,7 +74,7 @@ class FlutterTemplateApp extends StatelessWidget {
home: RefreshConfiguration( home: RefreshConfiguration(
enableLoadingWhenNoData: false, enableLoadingWhenNoData: false,
headerTriggerDistance: 80, headerTriggerDistance: 80,
child: const DemoPage(), child: const RecordingPage(),
), ),
); );
}, },

View File

@@ -1,10 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_template/app/app.dart'; import 'package:recording_tool/app/app.dart';
import 'package:flutter_template/app/config/app_config.dart'; import 'package:recording_tool/app/config/app_config.dart';
import 'package:flutter_template/core/cache/app_storage.dart'; import 'package:recording_tool/core/cache/app_storage.dart';
import 'package:flutter_template/core/logging/app_logger.dart'; import 'package:recording_tool/core/logging/app_logger.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
class AppBootstrapper { class AppBootstrapper {

View File

@@ -1,9 +1,9 @@
import 'dart:convert'; import 'dart:convert';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter_template/core/network/api_exception.dart'; import 'package:recording_tool/core/network/api_exception.dart';
import 'package:flutter_template/core/network/api_response.dart'; import 'package:recording_tool/core/network/api_response.dart';
import 'package:flutter_template/core/network/http_method.dart'; import 'package:recording_tool/core/network/http_method.dart';
typedef JsonParser<T> = T Function(dynamic json); typedef JsonParser<T> = T Function(dynamic json);

View File

@@ -1,10 +1,10 @@
import 'dart:io'; import 'dart:io';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter_template/app/config/app_config.dart'; import 'package:recording_tool/app/config/app_config.dart';
import 'package:flutter_template/core/cache/app_storage.dart'; import 'package:recording_tool/core/cache/app_storage.dart';
import 'package:flutter_template/core/cache/storage_keys.dart'; import 'package:recording_tool/core/cache/storage_keys.dart';
import 'package:flutter_template/core/utils/device_utils.dart'; import 'package:recording_tool/core/utils/device_utils.dart';
class HeaderInterceptor extends Interceptor { class HeaderInterceptor extends Interceptor {
@override @override

View File

@@ -3,7 +3,7 @@ import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.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 { class NetworkMonitor {
final _controller = StreamController<NetworkState>.broadcast(); final _controller = StreamController<NetworkState>.broadcast();

View File

@@ -1,7 +1,7 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter_template/core/network/network_monitor.dart'; import 'package:recording_tool/core/network/network_monitor.dart';
import 'package:flutter_template/core/network/offline_queue/offline_queue_manager.dart'; import 'package:recording_tool/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/offline_queue/offline_request.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
class OfflineQueueInterceptor extends Interceptor { class OfflineQueueInterceptor extends Interceptor {

View File

@@ -1,11 +1,11 @@
import 'dart:async'; import 'dart:async';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter_template/core/logging/app_logger.dart'; import 'package:recording_tool/core/logging/app_logger.dart';
import 'package:flutter_template/core/network/network_monitor.dart'; import 'package:recording_tool/core/network/network_monitor.dart';
import 'package:flutter_template/core/network/offline_queue/offline_queue_state.dart'; import 'package:recording_tool/core/network/offline_queue/offline_queue_state.dart';
import 'package:flutter_template/core/network/offline_queue/offline_queue_storage.dart'; import 'package:recording_tool/core/network/offline_queue/offline_queue_storage.dart';
import 'package:flutter_template/core/network/offline_queue/offline_request.dart'; import 'package:recording_tool/core/network/offline_queue/offline_request.dart';
class OfflineQueueManager { class OfflineQueueManager {
OfflineQueueManager({ OfflineQueueManager({

View File

@@ -1,6 +1,6 @@
import 'package:flutter_template/core/cache/app_storage.dart'; import 'package:recording_tool/core/cache/app_storage.dart';
import 'package:flutter_template/core/cache/storage_keys.dart'; import 'package:recording_tool/core/cache/storage_keys.dart';
import 'package:flutter_template/core/network/offline_queue/offline_request.dart'; import 'package:recording_tool/core/network/offline_queue/offline_request.dart';
class OfflineQueueStorage { class OfflineQueueStorage {
Future<List<OfflineRequest>> loadQueue() async { Future<List<OfflineRequest>> loadQueue() async {

View File

@@ -1,11 +1,11 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_template/app/config/app_config.dart'; import 'package:recording_tool/app/config/app_config.dart';
import 'package:flutter_template/core/network/api_client.dart'; import 'package:recording_tool/core/network/api_client.dart';
import 'package:flutter_template/core/network/header_interceptor.dart'; import 'package:recording_tool/core/network/header_interceptor.dart';
import 'package:flutter_template/core/network/offline_queue/offline_queue_interceptor.dart'; import 'package:recording_tool/core/network/offline_queue/offline_queue_interceptor.dart';
import 'package:flutter_template/core/network/providers/network_providers.dart'; import 'package:recording_tool/core/network/providers/network_providers.dart';
import 'package:flutter_template/core/network/providers/offline_queue_providers.dart'; import 'package:recording_tool/core/network/providers/offline_queue_providers.dart';
final dioProvider = Provider<Dio>((ref) { final dioProvider = Provider<Dio>((ref) {
final dio = Dio( final dio = Dio(

View File

@@ -1,6 +1,6 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_template/core/network/network_monitor.dart'; import 'package:recording_tool/core/network/network_monitor.dart';
import 'package:flutter_template/core/network/network_state.dart'; import 'package:recording_tool/core/network/network_state.dart';
final networkMonitorProvider = Provider<NetworkMonitor>((ref) { final networkMonitorProvider = Provider<NetworkMonitor>((ref) {
final monitor = NetworkMonitor()..start(); final monitor = NetworkMonitor()..start();

View File

@@ -1,8 +1,8 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_template/core/network/offline_queue/offline_queue_manager.dart'; import 'package:recording_tool/core/network/offline_queue/offline_queue_manager.dart';
import 'package:flutter_template/core/network/offline_queue/offline_queue_state.dart'; import 'package:recording_tool/core/network/offline_queue/offline_queue_state.dart';
import 'package:flutter_template/core/network/offline_queue/offline_queue_storage.dart'; import 'package:recording_tool/core/network/offline_queue/offline_queue_storage.dart';
import 'package:flutter_template/core/network/providers/network_providers.dart'; import 'package:recording_tool/core/network/providers/network_providers.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
final offlineQueueStorageProvider = Provider<OfflineQueueStorage>((ref) { final offlineQueueStorageProvider = Provider<OfflineQueueStorage>((ref) {

View File

@@ -17,6 +17,24 @@ class PermissionService {
return permissions.toList().request(); 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( static Future<bool> ensure(
Permission permission, { Permission permission, {
bool openSettingsWhenPermanentlyDenied = true, bool openSettingsWhenPermanentlyDenied = true,

View File

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

View File

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

View 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.');
}
}

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

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

View 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);
}

View File

@@ -3,12 +3,13 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.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: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 { class RecordingPage extends ConsumerStatefulWidget {
const RecordingPage({super.key}); const RecordingPage({super.key});
@@ -27,11 +28,20 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
} }
Future<void> _bootstrap() async { Future<void> _bootstrap() async {
final clipboardResult = await ref
.read(recordingViewModelProvider.notifier)
.getClipboardContent();
if (!mounted) return;
if (clipboardResult == ClipboardReadResult.invalid) {
AppToast.show('无选手信息');
}
await _enterRecordingMode(); await _enterRecordingMode();
// Allow PlatformView to attach before binding CameraX preview. // Allow PlatformView to attach before binding CameraX preview.
await Future<void>.delayed(const Duration(milliseconds: 400)); await Future<void>.delayed(const Duration(milliseconds: 400));
if (!mounted) return; if (!mounted) return;
await ref.read(recordingSessionControllerProvider.notifier).prepareSession(); await ref
.read(recordingSessionControllerProvider.notifier)
.prepareSession();
} }
Future<void> _enterRecordingMode() async { Future<void> _enterRecordingMode() async {
@@ -67,7 +77,10 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final state = ref.watch(recordingSessionControllerProvider); final state = ref.watch(recordingSessionControllerProvider);
final recordingInfo = ref.watch(recordingViewModelProvider);
final controller = ref.read(recordingSessionControllerProvider.notifier); final controller = ref.read(recordingSessionControllerProvider.notifier);
final clipboard = recordingInfo.clipboardRecordingModel;
final showClipboardInfo = recordingInfo.hasValidClipboardInfo;
return PopScope( return PopScope(
canPop: !state.isRecording, canPop: !state.isRecording,
@@ -93,8 +106,17 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
), ),
_RecordingHud( _RecordingHud(
state: state, state: state,
eventTitle: showClipboardInfo ? clipboard.title : null,
eventAddress: showClipboardInfo ? clipboard.address : null,
onStart: () => controller.startRecording(), 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 { onOpenDnd: () async {
await controller.openDndSettings(); await controller.openDndSettings();
await controller.refreshDndAccess(); await controller.refreshDndAccess();
@@ -117,6 +139,8 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
class _RecordingHud extends StatelessWidget { class _RecordingHud extends StatelessWidget {
const _RecordingHud({ const _RecordingHud({
required this.state, required this.state,
this.eventTitle,
this.eventAddress,
required this.onStart, required this.onStart,
required this.onStop, required this.onStop,
required this.onOpenDnd, required this.onOpenDnd,
@@ -125,48 +149,28 @@ class _RecordingHud extends StatelessWidget {
}); });
final RecordingSessionState state; final RecordingSessionState state;
final String? eventTitle;
final String? eventAddress;
final VoidCallback onStart; final VoidCallback onStart;
final VoidCallback onStop; final VoidCallback onStop;
final VoidCallback onOpenDnd; final VoidCallback onOpenDnd;
final VoidCallback onOpenBattery; final VoidCallback onOpenBattery;
final VoidCallback onToggleTouchLock; final VoidCallback onToggleTouchLock;
static const _overlayTextStyle = TextStyle(
color: Colors.white,
shadows: [Shadow(color: Colors.black54, blurRadius: 6)],
);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SafeArea( return SafeArea(
child: Column( child: Stack(
children: [ children: [
Padding( Column(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [ children: [
IconButton( SizedBox(
onPressed: state.isRecording height: eventTitle != null || state.isRecording ? 56 : 8,
? null
: () => Navigator.of(context).maybePop(),
icon: const Icon(Icons.close, color: Colors.white),
),
const Spacer(),
if (state.isRecording)
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,
),
),
),
],
),
), ),
const Spacer(), const Spacer(),
if (state.errorMessage != null) if (state.errorMessage != null)
@@ -180,10 +184,16 @@ class _RecordingHud extends StatelessWidget {
), ),
if (state.permissionWarning != null) if (state.permissionWarning != null)
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Text( child: Text(
state.permissionWarning!, state.permissionWarning!,
style: const TextStyle(color: Colors.orangeAccent, fontSize: 12), style: const TextStyle(
color: Colors.orangeAccent,
fontSize: 12,
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
), ),
@@ -220,7 +230,9 @@ class _RecordingHud extends StatelessWidget {
color: state.isRecording ? Colors.white : Colors.red, color: state.isRecording ? Colors.white : Colors.red,
), ),
child: Icon( child: Icon(
state.isRecording ? Icons.stop : Icons.fiber_manual_record, state.isRecording
? Icons.stop
: Icons.fiber_manual_record,
color: state.isRecording ? Colors.red : Colors.white, color: state.isRecording ? Colors.red : Colors.white,
size: 36, size: 36,
), ),
@@ -230,17 +242,77 @@ class _RecordingHud extends StatelessWidget {
], ],
), ),
), ),
if (state.lastOutputPath != null && !state.isRecording) if (state.lastSavedDisplayName != null &&
!state.isRecording &&
!state.gallerySaveFailed)
Padding( Padding(
padding: const EdgeInsets.only(bottom: 16), padding: const EdgeInsets.only(bottom: 16),
child: Text( child: Text(
'已保存:${state.lastOutputPath}', '已保存到相册${state.lastSavedDisplayName}',
style: const TextStyle(color: Colors.white70, fontSize: 12), style: const TextStyle(color: Colors.white70, fontSize: 12),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
), ),
], ],
), ),
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(
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), const SizedBox(height: 8),
], ],
if (!hasDndAccess) if (!hasDndAccess)
_HintChip( _HintChip(label: '开启勿扰权限可减少录制中断', onTap: onOpenDnd),
label: '开启勿扰权限可减少录制中断',
onTap: onOpenDnd,
),
if (!isBatteryIgnored) ...[ if (!isBatteryIgnored) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
_HintChip( _HintChip(label: '关闭电池优化可提升息屏续录稳定性', onTap: onOpenBattery),
label: '关闭电池优化可提升息屏续录稳定性',
onTap: onOpenBattery,
),
], ],
], ],
), ),

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:recording_tool/features/recording/recording_channel_names.dart';
enum RecordingState { enum RecordingState {
idle, idle,
@@ -47,13 +48,18 @@ class RecordingPlatform {
RecordingPlatform._(); RecordingPlatform._();
static const MethodChannel _channel = MethodChannel( static const MethodChannel _channel = MethodChannel(
'com.example.flutter_template/recording', RecordingChannelNames.method,
); );
static const EventChannel _events = EventChannel( 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; static Stream<RecordingStatus>? _statusStream;
@@ -61,9 +67,10 @@ class RecordingPlatform {
if (!isSupported) { if (!isSupported) {
return const Stream.empty(); return const Stream.empty();
} }
_statusStream ??= _events _statusStream ??= _events.receiveBroadcastStream().map(
.receiveBroadcastStream() (event) =>
.map((event) => RecordingStatus.fromMap(Map<dynamic, dynamic>.from(event as Map))); RecordingStatus.fromMap(Map<dynamic, dynamic>.from(event as Map)),
);
return _statusStream!; return _statusStream!;
} }
@@ -77,12 +84,14 @@ class RecordingPlatform {
static Future<RecordingStartResult> startRecording({ static Future<RecordingStartResult> startRecording({
bool withAudio = true, bool withAudio = true,
bool enableDoNotDisturb = true, bool enableDoNotDisturb = true,
String? displayName,
}) async { }) async {
final result = await _channel.invokeMapMethod<String, dynamic>( final result = await _channel.invokeMapMethod<String, dynamic>(
'startRecording', 'startRecording',
<String, dynamic>{ <String, dynamic>{
'withAudio': withAudio, 'withAudio': withAudio,
'enableDoNotDisturb': enableDoNotDisturb, 'enableDoNotDisturb': enableDoNotDisturb,
if (displayName != null) 'displayName': displayName,
}, },
); );
return RecordingStartResult( return RecordingStartResult(
@@ -97,15 +106,11 @@ class RecordingPlatform {
final result = await _channel.invokeMapMethod<String, dynamic>( final result = await _channel.invokeMapMethod<String, dynamic>(
'stopRecording', 'stopRecording',
); );
return RecordingStopResult( return RecordingStopResult.fromMap(result);
outputPath: result?['outputPath'] as String?,
status: RecordingStatus.fromMap(
Map<dynamic, dynamic>.from(result?['status'] as Map? ?? const {}),
),
);
} }
static Future<void> disposePreview() => _channel.invokeMethod('disposePreview'); static Future<void> disposePreview() =>
_channel.invokeMethod('disposePreview');
static Future<bool> hasNotificationPolicyAccess() async { static Future<bool> hasNotificationPolicyAccess() async {
return await _channel.invokeMethod<bool>('hasNotificationPolicyAccess') ?? return await _channel.invokeMethod<bool>('hasNotificationPolicyAccess') ??
@@ -136,10 +141,9 @@ class RecordingPlatform {
} }
static Future<void> setImmersiveMode({required bool enabled}) { static Future<void> setImmersiveMode({required bool enabled}) {
return _channel.invokeMethod( return _channel.invokeMethod('setImmersiveMode', <String, dynamic>{
'setImmersiveMode', 'enabled': enabled,
<String, dynamic>{'enabled': enabled}, });
);
} }
static Future<RecordingStatus> getStatus() async { static Future<RecordingStatus> getStatus() async {
@@ -156,8 +160,26 @@ class RecordingStartResult {
} }
class RecordingStopResult { 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 String? outputPath;
final RecordingStatus status; 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?,
);
}
} }

View File

@@ -3,7 +3,10 @@ import 'dart:io';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.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'; import 'package:permission_handler/permission_handler.dart';
class RecordingSessionState { class RecordingSessionState {
@@ -16,8 +19,10 @@ class RecordingSessionState {
this.notificationsGranted = true, this.notificationsGranted = true,
this.isMicrophoneGranted = false, this.isMicrophoneGranted = false,
this.lastOutputPath, this.lastOutputPath,
this.lastSavedDisplayName,
this.errorMessage, this.errorMessage,
this.permissionWarning, this.permissionWarning,
this.gallerySaveFailed = false,
}); });
final RecordingStatus status; final RecordingStatus status;
@@ -28,8 +33,10 @@ class RecordingSessionState {
final bool notificationsGranted; final bool notificationsGranted;
final bool isMicrophoneGranted; final bool isMicrophoneGranted;
final String? lastOutputPath; final String? lastOutputPath;
final String? lastSavedDisplayName;
final String? errorMessage; final String? errorMessage;
final String? permissionWarning; final String? permissionWarning;
final bool gallerySaveFailed;
bool get isRecording => status.isRecording; bool get isRecording => status.isRecording;
@@ -49,9 +56,12 @@ class RecordingSessionState {
bool? notificationsGranted, bool? notificationsGranted,
bool? isMicrophoneGranted, bool? isMicrophoneGranted,
String? lastOutputPath, String? lastOutputPath,
String? lastSavedDisplayName,
String? errorMessage, String? errorMessage,
String? permissionWarning, String? permissionWarning,
bool? gallerySaveFailed,
bool clearPermissionWarning = false, bool clearPermissionWarning = false,
bool clearLastSaved = false,
}) { }) {
return RecordingSessionState( return RecordingSessionState(
status: status ?? this.status, status: status ?? this.status,
@@ -63,10 +73,14 @@ class RecordingSessionState {
notificationsGranted: notificationsGranted ?? this.notificationsGranted, notificationsGranted: notificationsGranted ?? this.notificationsGranted,
isMicrophoneGranted: isMicrophoneGranted ?? this.isMicrophoneGranted, isMicrophoneGranted: isMicrophoneGranted ?? this.isMicrophoneGranted,
lastOutputPath: lastOutputPath ?? this.lastOutputPath, lastOutputPath: lastOutputPath ?? this.lastOutputPath,
lastSavedDisplayName: clearLastSaved
? null
: (lastSavedDisplayName ?? this.lastSavedDisplayName),
errorMessage: errorMessage, errorMessage: errorMessage,
permissionWarning: clearPermissionWarning permissionWarning: clearPermissionWarning
? null ? null
: (permissionWarning ?? this.permissionWarning), : (permissionWarning ?? this.permissionWarning),
gallerySaveFailed: gallerySaveFailed ?? this.gallerySaveFailed,
); );
} }
} }
@@ -74,7 +88,7 @@ class RecordingSessionState {
final recordingSessionControllerProvider = final recordingSessionControllerProvider =
NotifierProvider<RecordingSessionController, RecordingSessionState>( NotifierProvider<RecordingSessionController, RecordingSessionState>(
RecordingSessionController.new, RecordingSessionController.new,
); );
class RecordingSessionController extends Notifier<RecordingSessionState> { class RecordingSessionController extends Notifier<RecordingSessionState> {
StreamSubscription<RecordingStatus>? _statusSubscription; StreamSubscription<RecordingStatus>? _statusSubscription;
@@ -87,15 +101,16 @@ class RecordingSessionController extends Notifier<RecordingSessionState> {
Future<void> prepareSession() async { Future<void> prepareSession() async {
if (!RecordingPlatform.isSupported) { if (!RecordingPlatform.isSupported) {
state = state.copyWith(errorMessage: '仅支持 Android 录制'); state = state.copyWith(errorMessage: '当前设备不支持录制');
return; return;
} }
final permissions = await <Permission>[ final permissions = await PermissionService.requestMissing([
Permission.camera, Permission.camera,
Permission.microphone, Permission.microphone,
if (Platform.isAndroid) Permission.notification, if (Platform.isAndroid) Permission.notification,
].request(); ..._galleryPermissions(),
]);
final cameraGranted = permissions[Permission.camera]?.isGranted ?? false; final cameraGranted = permissions[Permission.camera]?.isGranted ?? false;
if (!cameraGranted) { if (!cameraGranted) {
@@ -116,6 +131,9 @@ class RecordingSessionController extends Notifier<RecordingSessionState> {
if (!microphoneGranted) { if (!microphoneGranted) {
warnings.add('未授予麦克风权限,当前将以静音模式录制'); warnings.add('未授予麦克风权限,当前将以静音模式录制');
} }
if (!_isGalleryPermissionGranted(permissions)) {
warnings.add('未授予相册权限,录制结束后可能无法保存到相册');
}
final hasDnd = await RecordingPlatform.hasNotificationPolicyAccess(); final hasDnd = await RecordingPlatform.hasNotificationPolicyAccess();
final batteryIgnored = final batteryIgnored =
@@ -160,26 +178,49 @@ class RecordingSessionController extends Notifier<RecordingSessionState> {
if (!shouldRetry) { if (!shouldRetry) {
rethrow; rethrow;
} }
await Future<void>.delayed( await Future<void>.delayed(Duration(milliseconds: 150 * (attempt + 1)));
Duration(milliseconds: 150 * (attempt + 1)),
);
} }
} }
throw StateError('initializePreview retry exhausted'); 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 { Future<void> startRecording({bool enableDoNotDisturb = true}) async {
if (!state.isPreviewReady || state.isRecording) return; if (!state.isPreviewReady || state.isRecording) return;
final clipboard = ref.read(recordingViewModelProvider).clipboardRecordingModel;
final displayName = recordingFileNameForPlatform(clipboard.filename);
try { try {
final result = await RecordingPlatform.startRecording( final result = await RecordingPlatform.startRecording(
enableDoNotDisturb: enableDoNotDisturb && state.hasDndAccess, enableDoNotDisturb: enableDoNotDisturb && state.hasDndAccess,
displayName: displayName,
); );
state = state.copyWith( state = state.copyWith(
status: result.status, status: result.status,
lastOutputPath: result.outputPath, lastOutputPath: result.outputPath,
isTouchLocked: true, isTouchLocked: true,
errorMessage: null, errorMessage: null,
gallerySaveFailed: false,
clearLastSaved: true,
); );
} on PlatformException catch (error) { } on PlatformException catch (error) {
state = state.copyWith(errorMessage: error.message ?? '开始录制失败'); state = state.copyWith(errorMessage: error.message ?? '开始录制失败');
@@ -191,10 +232,18 @@ class RecordingSessionController extends Notifier<RecordingSessionState> {
try { try {
final result = await RecordingPlatform.stopRecording(); final result = await RecordingPlatform.stopRecording();
final galleryFailed = !result.gallerySaved;
final savedName = recordingFileNameForPlatform(
ref.read(recordingViewModelProvider).clipboardRecordingModel.filename,
);
state = state.copyWith( state = state.copyWith(
status: result.status, status: result.status,
lastOutputPath: result.outputPath ?? state.lastOutputPath, lastOutputPath: result.outputPath ?? state.lastOutputPath,
errorMessage: null, lastSavedDisplayName: galleryFailed ? null : savedName,
errorMessage: galleryFailed
? (result.galleryErrorMessage ?? '保存到相册失败,请开启相册权限')
: null,
gallerySaveFailed: galleryFailed,
); );
} on PlatformException catch (error) { } on PlatformException catch (error) {
state = state.copyWith(errorMessage: error.message ?? '停止录制失败'); state = state.copyWith(errorMessage: error.message ?? '停止录制失败');

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

View File

@@ -8,13 +8,7 @@ class CameraPreviewWidget extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (!Platform.isAndroid) { if (Platform.isAndroid) {
return const ColoredBox(
color: Colors.black,
child: Center(child: Text('仅 Android 支持相机预览')),
);
}
return AndroidView( return AndroidView(
viewType: 'recording-camera-preview', viewType: 'recording-camera-preview',
layoutDirection: TextDirection.ltr, layoutDirection: TextDirection.ltr,
@@ -22,4 +16,19 @@ class CameraPreviewWidget extends StatelessWidget {
creationParamsCodec: const StandardMessageCodec(), 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('当前平台不支持相机预览')),
);
}
} }

View File

@@ -1,3 +1,3 @@
import 'package:flutter_template/app/bootstrap.dart'; import 'package:recording_tool/app/bootstrap.dart';
Future<void> main() => AppBootstrapper.bootstrap(); Future<void> main() => AppBootstrapper.bootstrap();

View File

@@ -1,5 +1,5 @@
import 'package:flutter/material.dart'; 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 { class AppAvatar extends StatelessWidget {
const AppAvatar({super.key, this.imageUrl, this.initials, this.size = 40}); const AppAvatar({super.key, this.imageUrl, this.initials, this.size = 40});

View File

@@ -1,5 +1,5 @@
import 'package:flutter/material.dart'; 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 { class AppCard extends StatelessWidget {
const AppCard({ const AppCard({

View File

@@ -1,5 +1,5 @@
import 'package:flutter/material.dart'; 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 { class AppErrorView extends StatelessWidget {
const AppErrorView({ const AppErrorView({

View File

@@ -1,5 +1,5 @@
import 'package:flutter/material.dart'; 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 { class AppSearchBar extends StatefulWidget {
const AppSearchBar({ const AppSearchBar({

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_template/shared/widgets/app_empty_view.dart'; import 'package:recording_tool/shared/widgets/app_empty_view.dart';
import 'package:flutter_template/shared/widgets/app_error_view.dart'; import 'package:recording_tool/shared/widgets/app_error_view.dart';
import 'package:flutter_template/shared/widgets/app_loading_view.dart'; import 'package:recording_tool/shared/widgets/app_loading_view.dart';
enum AppViewStatus { loading, empty, error, content } enum AppViewStatus { loading, empty, error, content }

View File

@@ -1,7 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; 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 { class AppToast {
AppToast._(); AppToast._();

View File

@@ -1,8 +1,8 @@
name: flutter_template name: recording_tool
description: "A reusable Flutter quick-start template for Android and iOS." description: "A recording tool for Android and iOS."
# The following line prevents the package from being accidentally published to # The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages. # 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. # The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43 # 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. # The following section is specific to Flutter packages.
flutter: flutter:
# The following line ensures that the Material Icons font is # The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in # included with your application, so that you can use the icons in
# the material Icons class. # the material Icons class.

View 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;
}
}

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

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

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

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

View File

@@ -1,14 +1,13 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_template/app/app.dart'; import 'package:recording_tool/app/app.dart';
void main() { 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.pumpWidget(const ProviderScope(child: FlutterTemplateApp()));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Flutter Template'), findsOneWidget); expect(find.byIcon(Icons.fiber_manual_record), findsOneWidget);
expect(find.text('通用 Flutter 快速开发模板'), findsOneWidget);
expect(find.text('增加计数'), findsOneWidget);
}); });
} }