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/
.swiftpm/
migrate_working_dir/
.vscode
# IntelliJ related
*.iml

View File

@@ -13,22 +13,22 @@ A production-ready Flutter quick-start template extracted from real-world projec
## Tech Stack
| Category | Package | Purpose |
|---|---|---|
| State Management | flutter_riverpod | Compile-safe, testable state management |
| Networking | dio | HTTP client with interceptor chain |
| Local Cache | shared_preferences | Key-value persistence |
| Network Monitor | connectivity_plus | Real-time connectivity tracking |
| Permissions | permission_handler | Runtime permission requests |
| Screen Adaptation | flutter_screenutil | Design-dimension-based layout |
| Image Loading | cached_network_image | Network image caching |
| SVG | flutter_svg | SVG rendering |
| Pull to Refresh | pull_to_refresh | Refresh and load-more |
| Loading HUD | flutter_easyloading | Toast and loading indicator |
| Device Info | device_info_plus | Device metadata |
| Package Info | package_info_plus | App version info |
| URL Launcher | url_launcher | Open external URLs |
| Linting | flutter_lints | Recommended Dart lint rules |
| Category | Package | Purpose |
| ----------------- | -------------------- | --------------------------------------- |
| State Management | flutter_riverpod | Compile-safe, testable state management |
| Networking | dio | HTTP client with interceptor chain |
| Local Cache | shared_preferences | Key-value persistence |
| Network Monitor | connectivity_plus | Real-time connectivity tracking |
| Permissions | permission_handler | Runtime permission requests |
| Screen Adaptation | flutter_screenutil | Design-dimension-based layout |
| Image Loading | cached_network_image | Network image caching |
| SVG | flutter_svg | SVG rendering |
| Pull to Refresh | pull_to_refresh | Refresh and load-more |
| Loading HUD | flutter_easyloading | Toast and loading indicator |
| Device Info | device_info_plus | Device metadata |
| Package Info | package_info_plus | App version info |
| URL Launcher | url_launcher | Open external URLs |
| Linting | flutter_lints | Recommended Dart lint rules |
## Directory Structure
@@ -72,7 +72,7 @@ lib/
## Getting Started
```bash
cd flutter-template
cd record-tool
flutter pub get
flutter analyze
flutter test

View File

@@ -13,22 +13,22 @@
## 技术栈
| 类别 | 依赖 | 用途 |
|---|---|---|
| 状态管理 | flutter_riverpod | 编译安全、可测试的状态管理 |
| 网络请求 | dio | HTTP 客户端,支持拦截器链 |
| 本地缓存 | shared_preferences | KV 持久化存储 |
| 网络监听 | connectivity_plus | 实时网络状态监测 |
| 权限申请 | permission_handler | 运行时权限请求 |
| 屏幕适配 | flutter_screenutil | 设计稿尺寸适配 |
| 图片加载 | cached_network_image | 网络图片缓存 |
| SVG | flutter_svg | SVG 渲染 |
| 下拉刷新 | pull_to_refresh | 下拉刷新 / 上拉加载 |
| 加载提示 | flutter_easyloading | Toast 和 loading |
| 设备信息 | device_info_plus | 设备元数据 |
| 应用信息 | package_info_plus | 版本号等应用信息 |
| 链接跳转 | url_launcher | 外部 URL 打开 |
| 代码规范 | flutter_lints | Dart 推荐 lint 规则 |
| 类别 | 依赖 | 用途 |
| -------- | -------------------- | -------------------------- |
| 状态管理 | flutter_riverpod | 编译安全、可测试的状态管理 |
| 网络请求 | dio | HTTP 客户端,支持拦截器链 |
| 本地缓存 | shared_preferences | KV 持久化存储 |
| 网络监听 | connectivity_plus | 实时网络状态监测 |
| 权限申请 | permission_handler | 运行时权限请求 |
| 屏幕适配 | flutter_screenutil | 设计稿尺寸适配 |
| 图片加载 | cached_network_image | 网络图片缓存 |
| SVG | flutter_svg | SVG 渲染 |
| 下拉刷新 | pull_to_refresh | 下拉刷新 / 上拉加载 |
| 加载提示 | flutter_easyloading | Toast 和 loading |
| 设备信息 | device_info_plus | 设备元数据 |
| 应用信息 | package_info_plus | 版本号等应用信息 |
| 链接跳转 | url_launcher | 外部 URL 打开 |
| 代码规范 | flutter_lints | Dart 推荐 lint 规则 |
## 目录结构
@@ -71,7 +71,7 @@ lib/
## 快速开始
```bash
cd flutter-template
cd record-tool
flutter pub get
flutter analyze
flutter test

View File

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

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

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 com.example.flutter_template.recording.RecordingPlatformHandler
import com.example.flutter_template.recording.RecordingPreviewFactory
import com.gdfw.fxjk.recording.RecordingPlatformHandler
import com.gdfw.fxjk.recording.RecordingPreviewFactory
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
package com.example.flutter_template.recording
package com.gdfw.fxjk.recording
enum class RecordingState {
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.enableJetifier=true
# This builtInKotlin flag was added automatically by Flutter migrator

View File

@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip

View File

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

View File

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

View File

@@ -39,5 +39,15 @@ end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
target.build_configurations.each do |config|
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [
'$(inherited)',
'PERMISSION_CAMERA=1',
'PERMISSION_MICROPHONE=1',
'PERMISSION_PHOTOS=1',
'PERMISSION_PHOTOS_ADD_ONLY=1',
]
end
end
end

View File

@@ -1,67 +1,22 @@
PODS:
- connectivity_plus (0.0.1):
- Flutter
- device_info_plus (0.0.1):
- Flutter
- Flutter (1.0.0)
- package_info_plus (0.4.5):
- Flutter
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- permission_handler_apple (9.3.0):
- Flutter
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- sqflite_darwin (0.0.4):
- Flutter
- FlutterMacOS
- url_launcher_ios (0.0.1):
- Flutter
DEPENDENCIES:
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- Flutter (from `Flutter`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
EXTERNAL SOURCES:
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
Flutter:
:path: Flutter
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sqflite_darwin:
:path: ".symlinks/plugins/sqflite_darwin/darwin"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
SPEC CHECKSUMS:
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
PODFILE CHECKSUM: 6f58d96c1fb09d1d57dbcbf38a5d856d7302054a
COCOAPODS: 1.16.2

View File

@@ -12,6 +12,8 @@
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
64BC3B3C75FABAA128C4FE7A /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D84231833EC084C45F76D9A0 /* Pods_RunnerTests.framework */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
8D5D9D9E2C69000100F10001 /* RecordingPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D5D9D9D2C69000100F10001 /* RecordingPlugin.swift */; };
87DA76CA5231F693C9392B80 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B6AA1C86CE710449CC3E8FCD /* Pods_Runner.framework */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
@@ -51,6 +53,8 @@
69561E7442E8CC939DB2B482 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
8D5D9D9D2C69000100F10001 /* RecordingPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingPlugin.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
95C6AC1536EA3DBA2D369C55 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
@@ -80,6 +84,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
87DA76CA5231F693C9392B80 /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -121,6 +126,7 @@
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
@@ -160,6 +166,7 @@
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
8D5D9D9D2C69000100F10001 /* RecordingPlugin.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
@@ -198,7 +205,6 @@
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
CD126DA202245CE8BB749787 /* [CP] Embed Pods Frameworks */,
62D3774184B6C76B8E34C8C2 /* [CP] Copy Pods Resources */,
);
buildRules = (
@@ -206,6 +212,9 @@
dependencies = (
);
name = Runner;
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
@@ -239,6 +248,9 @@
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
);
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
@@ -363,23 +375,6 @@
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
CD126DA202245CE8BB749787 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -396,6 +391,7 @@
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
8D5D9D9E2C69000100F10001 /* RecordingPlugin.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -744,6 +740,20 @@
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
productName = FlutterGeneratedPluginSwiftPackage;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}

View File

@@ -5,6 +5,24 @@
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Prepare Flutter Framework Script"
scriptText = "/bin/sh &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>
<BuildActionEntry
buildForTesting = "YES"

View File

@@ -2,12 +2,18 @@ import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate {
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "RecordingPlugin") {
RecordingPlugin.register(with: registrar)
}
}
}

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">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
@@ -24,6 +26,35 @@
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>需要访问相机以显示预览并录制视频。</string>
<key>NSMicrophoneUsageDescription</key>
<string>需要访问麦克风以录制视频声音;未授权时将静音录制。</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>需要将录制的视频保存到相册。</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>FlutterSceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
@@ -41,9 +72,5 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
</dict>
</plist>

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_easyloading/flutter_easyloading.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_template/app/config/app_config.dart';
import 'package:flutter_template/app/router/app_navigator.dart';
import 'package:flutter_template/app/theme/app_theme.dart';
import 'package:flutter_template/features/demo/demo_page.dart';
import 'package:recording_tool/app/config/app_config.dart';
import 'package:recording_tool/app/router/app_navigator.dart';
import 'package:recording_tool/app/theme/app_theme.dart';
import 'package:recording_tool/features/recording/recording_page.dart';
import 'package:recording_tool/features/recording/view-model/view_model_recording.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
class FlutterTemplateApp extends StatelessWidget {
class FlutterTemplateApp extends ConsumerStatefulWidget {
const FlutterTemplateApp({super.key});
@override
ConsumerState<FlutterTemplateApp> createState() => _FlutterTemplateAppState();
}
class _FlutterTemplateAppState extends ConsumerState<FlutterTemplateApp>
with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(recordingViewModelProvider.notifier).getClipboardContent();
});
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
ref.read(recordingViewModelProvider.notifier).getClipboardContent();
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
Widget build(BuildContext context) {
return ScreenUtilInit(
@@ -44,7 +74,7 @@ class FlutterTemplateApp extends StatelessWidget {
home: RefreshConfiguration(
enableLoadingWhenNoData: false,
headerTriggerDistance: 80,
child: const DemoPage(),
child: const RecordingPage(),
),
);
},

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_template/core/network/network_state.dart';
import 'package:recording_tool/core/network/network_state.dart';
class NetworkMonitor {
final _controller = StreamController<NetworkState>.broadcast();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,6 +17,24 @@ class PermissionService {
return permissions.toList().request();
}
/// 仅对尚未授予的权限发起系统授权弹窗,已授予则直接返回当前状态。
static Future<Map<Permission, PermissionStatus>> requestMissing(
Iterable<Permission> permissions,
) async {
final result = <Permission, PermissionStatus>{};
for (final permission in permissions) {
final current = await permission.status;
if (current.isGranted ||
current.isLimited ||
current.isPermanentlyDenied) {
result[permission] = current;
continue;
}
result[permission] = await permission.request();
}
return result;
}
static Future<bool> ensure(
Permission permission, {
bool openSettingsWhenPermanentlyDenied = true,

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/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_template/features/recording/recording_platform.dart';
import 'package:flutter_template/features/recording/recording_session_controller.dart';
import 'package:flutter_template/features/recording/widgets/camera_preview_widget.dart';
import 'package:flutter_template/features/recording/widgets/recording_touch_lock_overlay.dart';
import 'package:flutter_template/shared/widgets/widgets.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:recording_tool/features/recording/recording_platform.dart';
import 'package:recording_tool/features/recording/recording_session_controller.dart';
import 'package:recording_tool/features/recording/view-model/view_model_recording.dart';
import 'package:recording_tool/features/recording/widgets/camera_preview_widget.dart';
import 'package:recording_tool/features/recording/widgets/recording_touch_lock_overlay.dart';
import 'package:recording_tool/shared/widgets/widgets.dart';
class RecordingPage extends ConsumerStatefulWidget {
const RecordingPage({super.key});
@@ -27,11 +28,20 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
}
Future<void> _bootstrap() async {
final clipboardResult = await ref
.read(recordingViewModelProvider.notifier)
.getClipboardContent();
if (!mounted) return;
if (clipboardResult == ClipboardReadResult.invalid) {
AppToast.show('无选手信息');
}
await _enterRecordingMode();
// Allow PlatformView to attach before binding CameraX preview.
await Future<void>.delayed(const Duration(milliseconds: 400));
if (!mounted) return;
await ref.read(recordingSessionControllerProvider.notifier).prepareSession();
await ref
.read(recordingSessionControllerProvider.notifier)
.prepareSession();
}
Future<void> _enterRecordingMode() async {
@@ -67,7 +77,10 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
@override
Widget build(BuildContext context) {
final state = ref.watch(recordingSessionControllerProvider);
final recordingInfo = ref.watch(recordingViewModelProvider);
final controller = ref.read(recordingSessionControllerProvider.notifier);
final clipboard = recordingInfo.clipboardRecordingModel;
final showClipboardInfo = recordingInfo.hasValidClipboardInfo;
return PopScope(
canPop: !state.isRecording,
@@ -93,8 +106,17 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
),
_RecordingHud(
state: state,
eventTitle: showClipboardInfo ? clipboard.title : null,
eventAddress: showClipboardInfo ? clipboard.address : null,
onStart: () => controller.startRecording(),
onStop: () => controller.stopRecording(),
onStop: () async {
await controller.stopRecording();
if (!context.mounted) return;
final latest = ref.read(recordingSessionControllerProvider);
if (latest.gallerySaveFailed) {
AppToast.show(latest.errorMessage ?? '保存到相册失败,请开启相册权限');
}
},
onOpenDnd: () async {
await controller.openDndSettings();
await controller.refreshDndAccess();
@@ -117,6 +139,8 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
class _RecordingHud extends StatelessWidget {
const _RecordingHud({
required this.state,
this.eventTitle,
this.eventAddress,
required this.onStart,
required this.onStop,
required this.onOpenDnd,
@@ -125,118 +149,166 @@ class _RecordingHud extends StatelessWidget {
});
final RecordingSessionState state;
final String? eventTitle;
final String? eventAddress;
final VoidCallback onStart;
final VoidCallback onStop;
final VoidCallback onOpenDnd;
final VoidCallback onOpenBattery;
final VoidCallback onToggleTouchLock;
static const _overlayTextStyle = TextStyle(
color: Colors.white,
shadows: [Shadow(color: Colors.black54, blurRadius: 6)],
);
@override
Widget build(BuildContext context) {
return SafeArea(
child: Column(
child: Stack(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
IconButton(
onPressed: state.isRecording
? null
: () => Navigator.of(context).maybePop(),
icon: const Icon(Icons.close, color: Colors.white),
Column(
children: [
SizedBox(
height: eventTitle != null || state.isRecording ? 56 : 8,
),
const Spacer(),
if (state.errorMessage != null)
Padding(
padding: const EdgeInsets.all(12),
child: Text(
state.errorMessage!,
style: const TextStyle(color: Colors.amber),
textAlign: TextAlign.center,
),
),
const Spacer(),
if (state.isRecording)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
if (state.permissionWarning != null)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Text(
state.permissionWarning!,
style: const TextStyle(
color: Colors.orangeAccent,
fontSize: 12,
),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(20),
),
child: Text(
'REC ${state.elapsedLabel}',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
textAlign: TextAlign.center,
),
),
_SetupHints(
hasDndAccess: state.hasDndAccess,
isBatteryIgnored: state.isBatteryOptimizedIgnored,
notificationsGranted: state.notificationsGranted,
onOpenDnd: onOpenDnd,
onOpenBattery: onOpenBattery,
onOpenNotificationSettings: openAppSettings,
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
if (state.isRecording)
IconButton(
onPressed: onToggleTouchLock,
icon: Icon(
state.isTouchLocked ? Icons.lock : Icons.lock_open,
color: Colors.white,
size: 28,
),
),
GestureDetector(
onTap: state.isRecording ? onStop : onStart,
child: Container(
width: 76,
height: 76,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 4),
color: state.isRecording ? Colors.white : Colors.red,
),
child: Icon(
state.isRecording
? Icons.stop
: Icons.fiber_manual_record,
color: state.isRecording ? Colors.red : Colors.white,
size: 36,
),
),
),
),
],
),
),
const Spacer(),
if (state.errorMessage != null)
Padding(
padding: const EdgeInsets.all(12),
child: Text(
state.errorMessage!,
style: const TextStyle(color: Colors.amber),
textAlign: TextAlign.center,
const SizedBox(width: 48),
],
),
),
),
if (state.permissionWarning != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
state.permissionWarning!,
style: const TextStyle(color: Colors.orangeAccent, fontSize: 12),
textAlign: TextAlign.center,
),
),
_SetupHints(
hasDndAccess: state.hasDndAccess,
isBatteryIgnored: state.isBatteryOptimizedIgnored,
notificationsGranted: state.notificationsGranted,
onOpenDnd: onOpenDnd,
onOpenBattery: onOpenBattery,
onOpenNotificationSettings: openAppSettings,
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
if (state.isRecording)
IconButton(
onPressed: onToggleTouchLock,
icon: Icon(
state.isTouchLocked ? Icons.lock : Icons.lock_open,
color: Colors.white,
size: 28,
),
),
GestureDetector(
onTap: state.isRecording ? onStop : onStart,
child: Container(
width: 76,
height: 76,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 4),
color: state.isRecording ? Colors.white : Colors.red,
),
child: Icon(
state.isRecording ? Icons.stop : Icons.fiber_manual_record,
color: state.isRecording ? Colors.red : Colors.white,
size: 36,
),
if (state.lastSavedDisplayName != null &&
!state.isRecording &&
!state.gallerySaveFailed)
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Text(
'已保存到相册:${state.lastSavedDisplayName}',
style: const TextStyle(color: Colors.white70, fontSize: 12),
textAlign: TextAlign.center,
),
),
const SizedBox(width: 48),
],
),
],
),
if (state.lastOutputPath != null && !state.isRecording)
Padding(
padding: const EdgeInsets.only(bottom: 16),
if (eventTitle != null)
Positioned(
top: 8,
left: 12,
right: 12,
child: Padding(
padding: EdgeInsets.only(right: state.isRecording ? 96 : 0),
child: Text(
eventTitle!,
style: _overlayTextStyle.copyWith(
fontSize: 16,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
),
if (state.isRecording)
Positioned(
top: 8,
right: 12,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(20),
),
child: Text(
'REC ${state.elapsedLabel}',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
),
if (eventAddress != null && eventAddress!.isNotEmpty)
Positioned(
left: 16,
bottom: 108,
right: 120,
child: Text(
'已保存:${state.lastOutputPath}',
style: const TextStyle(color: Colors.white70, fontSize: 12),
textAlign: TextAlign.center,
eventAddress!,
style: _overlayTextStyle.copyWith(
fontSize: 13,
color: Colors.white70,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
@@ -280,16 +352,10 @@ class _SetupHints extends StatelessWidget {
const SizedBox(height: 8),
],
if (!hasDndAccess)
_HintChip(
label: '开启勿扰权限可减少录制中断',
onTap: onOpenDnd,
),
_HintChip(label: '开启勿扰权限可减少录制中断', onTap: onOpenDnd),
if (!isBatteryIgnored) ...[
const SizedBox(height: 8),
_HintChip(
label: '关闭电池优化可提升息屏续录稳定性',
onTap: onOpenBattery,
),
_HintChip(label: '关闭电池优化可提升息屏续录稳定性', onTap: onOpenBattery),
],
],
),

View File

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

View File

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

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

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

View File

@@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_template/shared/widgets/app_network_image.dart';
import 'package:recording_tool/shared/widgets/app_network_image.dart';
class AppAvatar extends StatelessWidget {
const AppAvatar({super.key, this.imageUrl, this.initials, this.size = 40});

View File

@@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_template/app/theme/app_theme.dart';
import 'package:recording_tool/app/theme/app_theme.dart';
class AppCard extends StatelessWidget {
const AppCard({

View File

@@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_template/shared/widgets/app_button.dart';
import 'package:recording_tool/shared/widgets/app_button.dart';
class AppErrorView extends StatelessWidget {
const AppErrorView({

View File

@@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_template/core/utils/rate_limiter.dart';
import 'package:recording_tool/core/utils/rate_limiter.dart';
class AppSearchBar extends StatefulWidget {
const AppSearchBar({

View File

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

View File

@@ -1,7 +1,7 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_template/app/router/app_navigator.dart';
import 'package:recording_tool/app/router/app_navigator.dart';
class AppToast {
AppToast._();

View File

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

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_test/flutter_test.dart';
import 'package:flutter_template/app/app.dart';
import 'package:recording_tool/app/app.dart';
void main() {
testWidgets('template app renders demo page', (tester) async {
testWidgets('recording app renders recording page', (tester) async {
await tester.pumpWidget(const ProviderScope(child: FlutterTemplateApp()));
await tester.pumpAndSettle();
expect(find.text('Flutter Template'), findsOneWidget);
expect(find.text('通用 Flutter 快速开发模板'), findsOneWidget);
expect(find.text('增加计数'), findsOneWidget);
expect(find.byIcon(Icons.fiber_manual_record), findsOneWidget);
});
}