From 66435302b3bbdbc86cd491ea5dbce7e7c6692f78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E9=94=8B?= <2535831261@qq.com> Date: Thu, 4 Jun 2026 13:35:10 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8D=87=E7=BA=A7=20Gradle=20=E2=86=92=208.14?= =?UTF-8?q?=E3=80=81AGP=20=E2=86=92=208.11=E3=80=81Kotlin=20=E2=86=92=202.?= =?UTF-8?q?2.20=20=20JVM=20=E5=A0=86=E9=99=8D=E5=88=B0=20-Xmx4G?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/src/main/AndroidManifest.xml | 7 +- .../recording/RecordingCameraController.kt | 34 +-- .../fxjk/recording/RecordingOutputFactory.kt | 51 ++++ .../recording/RecordingPlatformHandler.kt | 23 +- android/gradle.properties | 2 +- .../gradle/wrapper/gradle-wrapper.properties | 2 +- android/settings.gradle.kts | 4 +- ios/Podfile | 2 + ios/Runner/Info.plist | 2 + ios/Runner/RecordingPlugin.swift | 148 ++++++++-- .../recording/model/model_clipboard.dart | 18 +- .../recording/model/model_recording.dart | 15 +- .../recording/recording_display_name.dart | 53 ++++ lib/features/recording/recording_page.dart | 259 +++++++++++------- .../recording/recording_platform.dart | 29 +- .../recording_session_controller.dart | 54 +++- .../view-model/view_model_recording.dart | 54 +++- test.html | 56 ---- .../permission/permission_service_test.dart | 4 +- .../recording/model_clipboard_test.dart | 12 + .../recording_display_name_test.dart | 69 +++++ .../recording/view_model_recording_test.dart | 148 +++++----- 22 files changed, 755 insertions(+), 291 deletions(-) create mode 100644 android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingOutputFactory.kt create mode 100644 lib/features/recording/recording_display_name.dart delete mode 100644 test.html create mode 100644 test/features/recording/recording_display_name_test.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index de75510..6abbcf5 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ - + @@ -8,6 +9,10 @@ + + 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) diff --git a/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingOutputFactory.kt b/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingOutputFactory.kt new file mode 100644 index 0000000..0d04bab --- /dev/null +++ b/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingOutputFactory.kt @@ -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" + } +} diff --git a/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingPlatformHandler.kt b/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingPlatformHandler.kt index 69756cb..e9ee8c7 100644 --- a/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingPlatformHandler.kt +++ b/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingPlatformHandler.kt @@ -52,7 +52,8 @@ class RecordingPlatformHandler( "startRecording" -> { val withAudio = call.argument("withAudio") ?: true val enableDnd = call.argument("enableDoNotDisturb") ?: true - startRecording(withAudio, enableDnd, result) + val displayName = call.argument("displayName") + startRecording(withAudio, enableDnd, displayName, result) } "stopRecording" -> stopRecording(result) "disposePreview" -> { @@ -110,6 +111,7 @@ class RecordingPlatformHandler( private fun startRecording( withAudio: Boolean, enableDnd: Boolean, + displayName: String?, result: MethodChannel.Result, ) { val previewView = activity.recordingPreviewView @@ -125,7 +127,7 @@ class RecordingPlatformHandler( DoNotDisturbHelper.enable(activity) } - controller.startRecording(withAudio) { started, message -> + controller.startRecording(withAudio, displayName) { started, message -> mainHandler.post { if (started) { startElapsedTicker() @@ -170,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( + "outputPath" to path, + "status" to controller.status.toMap(), + "gallerySaved" to gallerySaved, ) + if (!gallerySaved) { + payload["galleryErrorMessage"] = + controller.status.message ?: "保存到相册失败" + } + result.success(payload) } } } diff --git a/android/gradle.properties b/android/gradle.properties index 475a628..b448bc2 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -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 diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index ac3b479..e4ef43f 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index fb605bc..ca7fe06 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -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") diff --git a/ios/Podfile b/ios/Podfile index e637465..fe802bc 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -45,6 +45,8 @@ post_install do |installer| '$(inherited)', 'PERMISSION_CAMERA=1', 'PERMISSION_MICROPHONE=1', + 'PERMISSION_PHOTOS=1', + 'PERMISSION_PHOTOS_ADD_ONLY=1', ] end end diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 89f9e5c..f54ad50 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -30,6 +30,8 @@ 需要访问相机以显示预览并录制视频。 NSMicrophoneUsageDescription 需要访问麦克风以录制视频声音;未授权时将静音录制。 + NSPhotoLibraryAddUsageDescription + 需要将录制的视频保存到相册。 UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/ios/Runner/RecordingPlugin.swift b/ios/Runner/RecordingPlugin.swift index c5e34db..3f0ee1b 100644 --- a/ios/Runner/RecordingPlugin.swift +++ b/ios/Runner/RecordingPlugin.swift @@ -1,5 +1,6 @@ import AVFoundation import Flutter +import Photos import UIKit private enum RecordingState: String { @@ -109,6 +110,9 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco 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? @@ -170,7 +174,7 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco } } - func startRecording(withAudio: Bool, result: @escaping FlutterResult) { + 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 @@ -192,8 +196,11 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco return } - let outputURL = try self.createOutputURL() - self.latestOutputPath = outputURL.path + 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) @@ -226,10 +233,16 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco guard self.movieOutput.isRecording else { DispatchQueue.main.async { - result([ - "outputPath": self.latestOutputPath, + 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 } @@ -291,24 +304,104 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco pendingStopResult = nil if let error { + latestGallerySaved = false + latestGalleryErrorMessage = error.localizedDescription updateStatus(RecordingStatus(state: .error, outputPath: latestOutputPath, message: error.localizedDescription)) - } else { - updateStatus( - RecordingStatus( - state: .previewing, - outputPath: latestOutputPath, - elapsedMillis: elapsedMillis() - ) - ) + 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() - stopResult?([ - "outputPath": self.latestOutputPath, + 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, "未授予相册权限") + } } } @@ -370,7 +463,7 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco AVCaptureDevice.authorizationStatus(for: .audio) == .authorized } - private func createOutputURL() throws -> URL { + private func createOutputURL(displayName: String?) throws -> URL { let baseURL = try FileManager.default.url( for: .documentDirectory, in: .userDomainMask, @@ -380,11 +473,23 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco 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" - let filename = "REC_\(formatter.string(from: Date())).mov" - return recordingsURL.appendingPathComponent(filename) + return "REC_\(formatter.string(from: Date())).mov" } private func updateStatus(_ next: RecordingStatus) { @@ -450,7 +555,8 @@ final class RecordingPlugin: NSObject, FlutterPlugin, FlutterStreamHandler { case "startRecording": let args = call.arguments as? [String: Any] let withAudio = args?["withAudio"] as? Bool ?? true - controller.startRecording(withAudio: withAudio, result: result) + let displayName = args?["displayName"] as? String + controller.startRecording(withAudio: withAudio, displayName: displayName, result: result) case "stopRecording": controller.stopRecording(result: result) case "disposePreview": diff --git a/lib/features/recording/model/model_clipboard.dart b/lib/features/recording/model/model_clipboard.dart index 0befd58..aa5db7b 100644 --- a/lib/features/recording/model/model_clipboard.dart +++ b/lib/features/recording/model/model_clipboard.dart @@ -1,15 +1,19 @@ -/// 剪切板内容数据模型 +/// 小程序复制到剪切板的录制信息。 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 json) { @@ -18,6 +22,7 @@ class ClipboardRecordingModel { startTimestamp: _readInt(json, 'startTimestamp'), endTimestamp: _readInt(json, 'endTimestamp'), address: _readString(json, 'address'), + filename: _readOptionalString(json, 'filename'), ); } @@ -27,9 +32,20 @@ class ClipboardRecordingModel { 'startTimestamp': startTimestamp, 'endTimestamp': endTimestamp, 'address': address, + if (filename != null) 'filename': filename, }; } + static String? _readOptionalString(Map 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 json, String key) { final value = json[key]; if (value is String) return value; diff --git a/lib/features/recording/model/model_recording.dart b/lib/features/recording/model/model_recording.dart index 2a7a4ed..f05f09f 100644 --- a/lib/features/recording/model/model_recording.dart +++ b/lib/features/recording/model/model_recording.dart @@ -4,7 +4,13 @@ class RecordingModel { /// 剪切板内容 final ClipboardRecordingModel clipboardRecordingModel; - RecordingModel({required this.clipboardRecordingModel}); + /// 剪切板是否包含有效的小程序录制信息 + final bool hasValidClipboardInfo; + + RecordingModel({ + required this.clipboardRecordingModel, + this.hasValidClipboardInfo = false, + }); factory RecordingModel.fromJson(Map json) { return RecordingModel( @@ -17,10 +23,15 @@ class RecordingModel { return {'clipboardRecordingModel': clipboardRecordingModel.toJson()}; } - RecordingModel copyWith({ClipboardRecordingModel? clipboardRecordingModel}) { + RecordingModel copyWith({ + ClipboardRecordingModel? clipboardRecordingModel, + bool? hasValidClipboardInfo, + }) { return RecordingModel( clipboardRecordingModel: clipboardRecordingModel ?? this.clipboardRecordingModel, + hasValidClipboardInfo: + hasValidClipboardInfo ?? this.hasValidClipboardInfo, ); } } diff --git a/lib/features/recording/recording_display_name.dart b/lib/features/recording/recording_display_name.dart new file mode 100644 index 0000000..1d6ab2c --- /dev/null +++ b/lib/features/recording/recording_display_name.dart @@ -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); +} diff --git a/lib/features/recording/recording_page.dart b/lib/features/recording/recording_page.dart index a14ea52..3273909 100644 --- a/lib/features/recording/recording_page.dart +++ b/lib/features/recording/recording_page.dart @@ -3,13 +3,13 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.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'; -import 'package:permission_handler/permission_handler.dart'; class RecordingPage extends ConsumerStatefulWidget { const RecordingPage({super.key}); @@ -28,7 +28,13 @@ class _RecordingPageState extends ConsumerState { } Future _bootstrap() async { - await ref.read(recordingViewModelProvider.notifier).getClipboardContent(); + 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.delayed(const Duration(milliseconds: 400)); @@ -71,7 +77,10 @@ class _RecordingPageState extends ConsumerState { @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, @@ -97,8 +106,17 @@ class _RecordingPageState extends ConsumerState { ), _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(); @@ -121,6 +139,8 @@ class _RecordingPageState extends ConsumerState { class _RecordingHud extends StatelessWidget { const _RecordingHud({ required this.state, + this.eventTitle, + this.eventAddress, required this.onStart, required this.onStop, required this.onOpenDnd, @@ -129,117 +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: [ - const Spacer(), - if (state.isRecording) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, + 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, + ), + ), + 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, - ), - ), - 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, + const SizedBox(width: 48), + ], ), - 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, ), ), ], diff --git a/lib/features/recording/recording_platform.dart b/lib/features/recording/recording_platform.dart index 593d211..5cb55c0 100644 --- a/lib/features/recording/recording_platform.dart +++ b/lib/features/recording/recording_platform.dart @@ -84,12 +84,14 @@ class RecordingPlatform { static Future startRecording({ bool withAudio = true, bool enableDoNotDisturb = true, + String? displayName, }) async { final result = await _channel.invokeMapMethod( 'startRecording', { 'withAudio': withAudio, 'enableDoNotDisturb': enableDoNotDisturb, + if (displayName != null) 'displayName': displayName, }, ); return RecordingStartResult( @@ -104,12 +106,7 @@ class RecordingPlatform { final result = await _channel.invokeMapMethod( 'stopRecording', ); - return RecordingStopResult( - outputPath: result?['outputPath'] as String?, - status: RecordingStatus.fromMap( - Map.from(result?['status'] as Map? ?? const {}), - ), - ); + return RecordingStopResult.fromMap(result); } static Future disposePreview() => @@ -163,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? result) { + return RecordingStopResult( + outputPath: result?['outputPath'] as String?, + status: RecordingStatus.fromMap( + Map.from(result?['status'] as Map? ?? const {}), + ), + gallerySaved: result?['gallerySaved'] as bool? ?? true, + galleryErrorMessage: result?['galleryErrorMessage'] as String?, + ); + } } diff --git a/lib/features/recording/recording_session_controller.dart b/lib/features/recording/recording_session_controller.dart index 70dcd0d..87a53fb 100644 --- a/lib/features/recording/recording_session_controller.dart +++ b/lib/features/recording/recording_session_controller.dart @@ -4,7 +4,9 @@ import 'dart:io'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.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 { @@ -17,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; @@ -29,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; @@ -50,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, @@ -64,10 +73,14 @@ 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, ); } } @@ -88,7 +101,7 @@ class RecordingSessionController extends Notifier { Future prepareSession() async { if (!RecordingPlatform.isSupported) { - state = state.copyWith(errorMessage: '仅支持 Android 录制'); + state = state.copyWith(errorMessage: '当前设备不支持录制'); return; } @@ -96,6 +109,7 @@ class RecordingSessionController extends Notifier { Permission.camera, Permission.microphone, if (Platform.isAndroid) Permission.notification, + ..._galleryPermissions(), ]); final cameraGranted = permissions[Permission.camera]?.isGranted ?? false; @@ -117,6 +131,9 @@ class RecordingSessionController extends Notifier { if (!microphoneGranted) { warnings.add('未授予麦克风权限,当前将以静音模式录制'); } + if (!_isGalleryPermissionGranted(permissions)) { + warnings.add('未授予相册权限,录制结束后可能无法保存到相册'); + } final hasDnd = await RecordingPlatform.hasNotificationPolicyAccess(); final batteryIgnored = @@ -167,18 +184,43 @@ class RecordingSessionController extends Notifier { throw StateError('initializePreview retry exhausted'); } + List _galleryPermissions() { + if (Platform.isIOS) { + return [Permission.photosAddOnly, Permission.photos]; + } + if (Platform.isAndroid) { + return [Permission.videos, Permission.storage]; + } + return const []; + } + + bool _isGalleryPermissionGranted(Map permissions) { + for (final permission in _galleryPermissions()) { + if (permissions[permission]?.isGranted ?? false) { + return true; + } + } + return _galleryPermissions().isEmpty; + } + Future 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 ?? '开始录制失败'); @@ -190,10 +232,18 @@ class RecordingSessionController extends Notifier { 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 ?? '停止录制失败'); diff --git a/lib/features/recording/view-model/view_model_recording.dart b/lib/features/recording/view-model/view_model_recording.dart index 7fc59c0..1b2ec4a 100644 --- a/lib/features/recording/view-model/view_model_recording.dart +++ b/lib/features/recording/view-model/view_model_recording.dart @@ -12,6 +12,18 @@ final recordingViewModelProvider = return RecordingViewModel(ref); }); +/// 剪切板读取结果,供 UI 决定是否提示用户。 +enum ClipboardReadResult { + /// 剪切板为空,不提示 + empty, + + /// 解析成功 + success, + + /// 有内容但格式不符合小程序录制信息 + invalid, +} + class RecordingViewModel extends StateNotifier { RecordingViewModel(this.ref) : super( @@ -26,8 +38,15 @@ class RecordingViewModel extends StateNotifier { ); final Ref ref; - /// 从剪切板获取内容 - Future getClipboardContent() async { + static final _defaultClipboard = ClipboardRecordingModel( + title: '', + startTimestamp: 0, + endTimestamp: 0, + address: '', + ); + + /// 从剪切板获取小程序复制的录制信息。 + Future getClipboardContent() async { try { final clipboardData = await Clipboard.getData(Clipboard.kTextPlain); final text = clipboardData?.text; @@ -35,22 +54,45 @@ class RecordingViewModel extends StateNotifier { if (text == null || text.trim().isEmpty) { AppLogger.info('剪切板内容为空,跳过录制信息解析'); - return; + _resetClipboardInfo(); + return ClipboardReadResult.empty; } - final decoded = jsonDecode(text); + final decoded = jsonDecode(text.trim()); if (decoded is! Map) { AppLogger.warning('剪切板内容不是 JSON 对象,跳过录制信息解析'); - return; + _resetClipboardInfo(); + return ClipboardReadResult.invalid; } final clipboardRecordingModel = ClipboardRecordingModel.fromJson(decoded); - state = state.copyWith(clipboardRecordingModel: clipboardRecordingModel); + 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, + ); + } } diff --git a/test.html b/test.html deleted file mode 100644 index f27d3c0..0000000 --- a/test.html +++ /dev/null @@ -1,56 +0,0 @@ - - - - - - - 复制赛事录制码 - - - - - - - - - - - \ No newline at end of file diff --git a/test/core/permission/permission_service_test.dart b/test/core/permission/permission_service_test.dart index 74e94bd..6c3062b 100644 --- a/test/core/permission/permission_service_test.dart +++ b/test/core/permission/permission_service_test.dart @@ -71,7 +71,7 @@ void main() { }); group('iOS permission configuration', () { - test('Podfile enables camera and microphone permission macros', () { + test('Podfile enables camera, microphone and photos permission macros', () { final podfile = File('ios/Podfile').readAsStringSync(); expect( @@ -80,6 +80,8 @@ void main() { ); 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'")); }); }); } diff --git a/test/features/recording/model_clipboard_test.dart b/test/features/recording/model_clipboard_test.dart index 762e0b0..7bb652b 100644 --- a/test/features/recording/model_clipboard_test.dart +++ b/test/features/recording/model_clipboard_test.dart @@ -17,9 +17,21 @@ void main() { 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.from(clipboardJson)..remove('title'); diff --git a/test/features/recording/recording_display_name_test.dart b/test/features/recording/recording_display_name_test.dart new file mode 100644 index 0000000..f16e39e --- /dev/null +++ b/test/features/recording/recording_display_name_test.dart @@ -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"fh|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', + ); + }); + }); +} diff --git a/test/features/recording/view_model_recording_test.dart b/test/features/recording/view_model_recording_test.dart index 20ebcc9..557c684 100644 --- a/test/features/recording/view_model_recording_test.dart +++ b/test/features/recording/view_model_recording_test.dart @@ -8,7 +8,7 @@ void main() { const defaultClipboardTitle = ''; const validClipboardText = - '{"title":"王东方 丨李想 空中格斗赛","startTimestamp":1717334400,"endTimestamp":1717334400,"address":"广州市番禺区·粤港澳大湾区青年人才双创小镇"}'; + '{"title":"王东方 丨李想 空中格斗赛","startTimestamp":1717334400,"endTimestamp":1717334400,"filename":"选手名称_选手ID_赛事名称_赛项","address":"广州市番禺区·粤港澳大湾区青年人才双创小镇"}'; Future setClipboardText(String? text) async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger @@ -33,116 +33,128 @@ void main() { final container = ProviderContainer(); addTearDown(container.dispose); - await container + final result = await container .read(recordingViewModelProvider.notifier) .getClipboardContent(); - final clipboardModel = container - .read(recordingViewModelProvider) - .clipboardRecordingModel; - expect(clipboardModel.title, '王东方 丨李想 空中格斗赛'); - expect(clipboardModel.startTimestamp, 1717334400); - expect(clipboardModel.endTimestamp, 1717334400); - expect(clipboardModel.address, '广州市番禺区·粤港澳大湾区青年人才双创小镇'); + 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('keeps default state when clipboard is empty', () async { + test('returns empty when clipboard is empty', () async { await setClipboardText(''); final container = ProviderContainer(); addTearDown(container.dispose); - await container + final result = await container .read(recordingViewModelProvider.notifier) .getClipboardContent(); - expect( - container - .read(recordingViewModelProvider) - .clipboardRecordingModel - .title, - defaultClipboardTitle, - ); + expect(result, ClipboardReadResult.empty); + final model = container.read(recordingViewModelProvider); + expect(model.hasValidClipboardInfo, isFalse); + expect(model.clipboardRecordingModel.title, defaultClipboardTitle); }); - test('keeps default state when clipboard is not JSON', () async { + test('returns invalid when clipboard is not JSON', () async { await setClipboardText('hello'); final container = ProviderContainer(); addTearDown(container.dispose); - await container + final result = await container .read(recordingViewModelProvider.notifier) .getClipboardContent(); + expect(result, ClipboardReadResult.invalid); expect( - container - .read(recordingViewModelProvider) - .clipboardRecordingModel - .title, + container.read(recordingViewModelProvider).clipboardRecordingModel.title, defaultClipboardTitle, ); + expect( + container.read(recordingViewModelProvider).hasValidClipboardInfo, + isFalse, + ); }); - test('keeps default state when clipboard JSON is not an object', () async { + test('returns invalid when clipboard JSON is not an object', () async { await setClipboardText('[1,2,3]'); final container = ProviderContainer(); addTearDown(container.dispose); - await container + final result = await container .read(recordingViewModelProvider.notifier) .getClipboardContent(); + expect(result, ClipboardReadResult.invalid); expect( - container - .read(recordingViewModelProvider) - .clipboardRecordingModel - .title, + container.read(recordingViewModelProvider).clipboardRecordingModel.title, defaultClipboardTitle, ); }); - test( - 'keeps default state when clipboard JSON misses required fields', - () async { - await setClipboardText('{"title":"王东方 丨李想 空中格斗赛"}'); - final container = ProviderContainer(); - addTearDown(container.dispose); + test('returns invalid when clipboard JSON misses required fields', () async { + await setClipboardText('{"title":"王东方 丨李想 空中格斗赛"}'); + final container = ProviderContainer(); + addTearDown(container.dispose); - await container - .read(recordingViewModelProvider.notifier) - .getClipboardContent(); + final result = await container + .read(recordingViewModelProvider.notifier) + .getClipboardContent(); - expect( - container - .read(recordingViewModelProvider) - .clipboardRecordingModel - .title, - defaultClipboardTitle, - ); - }, - ); + expect(result, ClipboardReadResult.invalid); + expect( + container.read(recordingViewModelProvider).clipboardRecordingModel.title, + defaultClipboardTitle, + ); + }); - test( - 'keeps default state when clipboard JSON has wrong field type', - () async { - await setClipboardText( - '{"title":"王东方 丨李想 空中格斗赛","startTimestamp":"1717334400","endTimestamp":1717334400,"address":"广州市番禺区·粤港澳大湾区青年人才双创小镇"}', - ); - final container = ProviderContainer(); - addTearDown(container.dispose); + 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); - await container - .read(recordingViewModelProvider.notifier) - .getClipboardContent(); + final result = await container + .read(recordingViewModelProvider.notifier) + .getClipboardContent(); - expect( - container - .read(recordingViewModelProvider) - .clipboardRecordingModel - .title, - defaultClipboardTitle, - ); - }, - ); + 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, + ); + }); }); }