From 8f9f3a9779a010e31379023924e806753e69b399 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E9=94=8B?= <2535831261@qq.com> Date: Wed, 3 Jun 2026 23:37:02 +0800 Subject: [PATCH] =?UTF-8?q?=E5=85=BC=E5=AE=B9=20IOS=20=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + ios/Flutter/AppFrameworkInfo.plist | 2 - ios/Podfile | 8 + ios/Podfile.lock | 47 +- ios/Runner.xcodeproj/project.pbxproj | 46 +- .../xcshareddata/xcschemes/Runner.xcscheme | 18 + ios/Runner/AppDelegate.swift | 10 +- ios/Runner/Info.plist | 33 +- ios/Runner/RecordingPlugin.swift | 485 ++++++++++++++++++ lib/app/app.dart | 4 +- lib/core/permission/permission_service.dart | 18 + lib/features/demo/demo_controller.dart | 29 -- lib/features/demo/demo_page.dart | 130 ----- lib/features/recording/recording_page.dart | 29 +- .../recording/recording_platform.dart | 24 +- .../recording_session_controller.dart | 5 +- .../widgets/camera_preview_widget.dart | 27 +- lib/features/webview/test.html | 56 ++ .../permission/permission_service_test.dart | 124 +++++ .../recording/recording_platform_test.dart | 21 + 20 files changed, 847 insertions(+), 270 deletions(-) create mode 100644 ios/Runner/RecordingPlugin.swift delete mode 100644 lib/features/demo/demo_controller.dart delete mode 100644 lib/features/demo/demo_page.dart create mode 100644 lib/features/webview/test.html create mode 100644 test/core/permission/permission_service_test.dart create mode 100644 test/features/recording/recording_platform_test.dart diff --git a/.gitignore b/.gitignore index 3820a95..b479a87 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ .svn/ .swiftpm/ migrate_working_dir/ +.vscode # IntelliJ related *.iml diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist index 1dc6cf7..391a902 100644 --- a/ios/Flutter/AppFrameworkInfo.plist +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -20,7 +20,5 @@ ???? CFBundleVersion 1.0 - MinimumOSVersion - 13.0 diff --git a/ios/Podfile b/ios/Podfile index 620e46e..e637465 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -39,5 +39,13 @@ 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', + ] + end end end diff --git a/ios/Podfile.lock b/ios/Podfile.lock index d06d55a..5c99568 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -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 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 9d676a7..5e5dbfe 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -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 = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 8D5D9D9D2C69000100F10001 /* RecordingPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingPlugin.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 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 = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; @@ -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 */; } diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index e3773d4..c3fedb2 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -5,6 +5,24 @@ + + + + + + + + + + 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) + } + } } diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 6443a21..89f9e5c 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -2,6 +2,8 @@ + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName @@ -24,6 +26,33 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + NSCameraUsageDescription + 需要访问相机以显示预览并录制视频。 + NSMicrophoneUsageDescription + 需要访问麦克风以录制视频声音;未授权时将静音录制。 + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + FlutterSceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -41,9 +70,5 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - diff --git a/ios/Runner/RecordingPlugin.swift b/ios/Runner/RecordingPlugin.swift new file mode 100644 index 0000000..7722915 --- /dev/null +++ b/ios/Runner/RecordingPlugin.swift @@ -0,0 +1,485 @@ +import AVFoundation +import Flutter +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 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, 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 + } + + let outputURL = try self.createOutputURL() + self.latestOutputPath = outputURL.path + 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 { + result([ + "outputPath": self.latestOutputPath, + "status": self.currentStatusMap(), + ]) + } + 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 { + updateStatus(RecordingStatus(state: .error, outputPath: latestOutputPath, message: error.localizedDescription)) + } else { + updateStatus( + RecordingStatus( + state: .previewing, + outputPath: latestOutputPath, + elapsedMillis: elapsedMillis() + ) + ) + } + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.stopElapsedTimer() + stopResult?([ + "outputPath": self.latestOutputPath, + "status": self.currentStatusMap(), + ]) + } + } + + 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() 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 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) + } + + 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 + } +} + +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: "com.example.flutter_template/recording", + binaryMessenger: messenger + ) + registrar.addMethodCallDelegate(plugin, channel: methodChannel) + + let eventChannel = FlutterEventChannel( + name: "com.example.flutter_template/recording_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 + controller.startRecording(withAudio: withAudio, 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 + } +} diff --git a/lib/app/app.dart b/lib/app/app.dart index b5c2b7c..c6332fb 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -5,7 +5,7 @@ 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:flutter_template/features/recording/recording_page.dart'; import 'package:pull_to_refresh/pull_to_refresh.dart'; class FlutterTemplateApp extends StatelessWidget { @@ -44,7 +44,7 @@ class FlutterTemplateApp extends StatelessWidget { home: RefreshConfiguration( enableLoadingWhenNoData: false, headerTriggerDistance: 80, - child: const DemoPage(), + child: const RecordingPage(), ), ); }, diff --git a/lib/core/permission/permission_service.dart b/lib/core/permission/permission_service.dart index c5b5f37..b425da9 100644 --- a/lib/core/permission/permission_service.dart +++ b/lib/core/permission/permission_service.dart @@ -17,6 +17,24 @@ class PermissionService { return permissions.toList().request(); } + /// 仅对尚未授予的权限发起系统授权弹窗,已授予则直接返回当前状态。 + static Future> requestMissing( + Iterable permissions, + ) async { + final result = {}; + 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 ensure( Permission permission, { bool openSettingsWhenPermanentlyDenied = true, diff --git a/lib/features/demo/demo_controller.dart b/lib/features/demo/demo_controller.dart deleted file mode 100644 index 4c67782..0000000 --- a/lib/features/demo/demo_controller.dart +++ /dev/null @@ -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 { - @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.new, -); diff --git a/lib/features/demo/demo_page.dart b/lib/features/demo/demo_page.dart deleted file mode 100644 index c351c7d..0000000 --- a/lib/features/demo/demo_page.dart +++ /dev/null @@ -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( - 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(), - ), - ], - ), - ), - ); - } -} diff --git a/lib/features/recording/recording_page.dart b/lib/features/recording/recording_page.dart index 24a0022..e78b55f 100644 --- a/lib/features/recording/recording_page.dart +++ b/lib/features/recording/recording_page.dart @@ -31,7 +31,9 @@ class _RecordingPageState extends ConsumerState { // Allow PlatformView to attach before binding CameraX preview. await Future.delayed(const Duration(milliseconds: 400)); if (!mounted) return; - await ref.read(recordingSessionControllerProvider.notifier).prepareSession(); + await ref + .read(recordingSessionControllerProvider.notifier) + .prepareSession(); } Future _enterRecordingMode() async { @@ -140,12 +142,6 @@ class _RecordingHud extends StatelessWidget { 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), - ), const Spacer(), if (state.isRecording) Container( @@ -183,7 +179,10 @@ class _RecordingHud extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Text( state.permissionWarning!, - style: const TextStyle(color: Colors.orangeAccent, fontSize: 12), + style: const TextStyle( + color: Colors.orangeAccent, + fontSize: 12, + ), textAlign: TextAlign.center, ), ), @@ -220,7 +219,9 @@ class _RecordingHud extends StatelessWidget { color: state.isRecording ? Colors.white : Colors.red, ), child: Icon( - state.isRecording ? Icons.stop : Icons.fiber_manual_record, + state.isRecording + ? Icons.stop + : Icons.fiber_manual_record, color: state.isRecording ? Colors.red : Colors.white, size: 36, ), @@ -280,16 +281,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), ], ], ), diff --git a/lib/features/recording/recording_platform.dart b/lib/features/recording/recording_platform.dart index b225e37..c43dc8a 100644 --- a/lib/features/recording/recording_platform.dart +++ b/lib/features/recording/recording_platform.dart @@ -53,7 +53,12 @@ class RecordingPlatform { 'com.example.flutter_template/recording_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? _statusStream; @@ -61,9 +66,10 @@ class RecordingPlatform { if (!isSupported) { return const Stream.empty(); } - _statusStream ??= _events - .receiveBroadcastStream() - .map((event) => RecordingStatus.fromMap(Map.from(event as Map))); + _statusStream ??= _events.receiveBroadcastStream().map( + (event) => + RecordingStatus.fromMap(Map.from(event as Map)), + ); return _statusStream!; } @@ -105,7 +111,8 @@ class RecordingPlatform { ); } - static Future disposePreview() => _channel.invokeMethod('disposePreview'); + static Future disposePreview() => + _channel.invokeMethod('disposePreview'); static Future hasNotificationPolicyAccess() async { return await _channel.invokeMethod('hasNotificationPolicyAccess') ?? @@ -136,10 +143,9 @@ class RecordingPlatform { } static Future setImmersiveMode({required bool enabled}) { - return _channel.invokeMethod( - 'setImmersiveMode', - {'enabled': enabled}, - ); + return _channel.invokeMethod('setImmersiveMode', { + 'enabled': enabled, + }); } static Future getStatus() async { diff --git a/lib/features/recording/recording_session_controller.dart b/lib/features/recording/recording_session_controller.dart index 89f1b6e..440f6a2 100644 --- a/lib/features/recording/recording_session_controller.dart +++ b/lib/features/recording/recording_session_controller.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_template/core/permission/permission_service.dart'; import 'package:flutter_template/features/recording/recording_platform.dart'; import 'package:permission_handler/permission_handler.dart'; @@ -91,11 +92,11 @@ class RecordingSessionController extends Notifier { return; } - final permissions = await [ + final permissions = await PermissionService.requestMissing([ Permission.camera, Permission.microphone, if (Platform.isAndroid) Permission.notification, - ].request(); + ]); final cameraGranted = permissions[Permission.camera]?.isGranted ?? false; if (!cameraGranted) { diff --git a/lib/features/recording/widgets/camera_preview_widget.dart b/lib/features/recording/widgets/camera_preview_widget.dart index 62472fd..7c8def4 100644 --- a/lib/features/recording/widgets/camera_preview_widget.dart +++ b/lib/features/recording/widgets/camera_preview_widget.dart @@ -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 {}, + creationParamsCodec: const StandardMessageCodec(), ); } - return AndroidView( - viewType: 'recording-camera-preview', - layoutDirection: TextDirection.ltr, - creationParams: const {}, - creationParamsCodec: const StandardMessageCodec(), + if (Platform.isIOS) { + return UiKitView( + viewType: 'recording-camera-preview', + layoutDirection: TextDirection.ltr, + creationParams: const {}, + creationParamsCodec: const StandardMessageCodec(), + ); + } + + return const ColoredBox( + color: Colors.black, + child: Center(child: Text('当前平台不支持相机预览')), ); } } diff --git a/lib/features/webview/test.html b/lib/features/webview/test.html new file mode 100644 index 0000000..f27d3c0 --- /dev/null +++ b/lib/features/webview/test.html @@ -0,0 +1,56 @@ + + + + + + + 复制赛事录制码 + + + + + + + + + + + \ No newline at end of file diff --git a/test/core/permission/permission_service_test.dart b/test/core/permission/permission_service_test.dart new file mode 100644 index 0000000..ac5b39c --- /dev/null +++ b/test/core/permission/permission_service_test.dart @@ -0,0 +1,124 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:permission_handler_platform_interface/permission_handler_platform_interface.dart'; + +import 'package:flutter_template/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.camera: PermissionStatus.granted, + Permission.microphone: PermissionStatus.denied, + }, + requestResults: { + Permission.microphone: PermissionStatus.granted, + }, + ); + PermissionHandlerPlatform.instance = platform; + + final result = await PermissionService.requestMissing([ + Permission.camera, + Permission.microphone, + ]); + + expect(platform.requestCalls, >[ + [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.camera: PermissionStatus.permanentlyDenied, + Permission.microphone: PermissionStatus.denied, + }, + requestResults: { + Permission.microphone: PermissionStatus.granted, + }, + ); + PermissionHandlerPlatform.instance = platform; + + final result = await PermissionService.requestMissing([ + Permission.camera, + Permission.microphone, + ]); + + expect(platform.requestCalls, >[ + [Permission.microphone], + ]); + expect(result[Permission.camera], PermissionStatus.permanentlyDenied); + expect(result[Permission.microphone], PermissionStatus.granted); + }); + }); + + group('iOS permission configuration', () { + test('Podfile enables camera and microphone 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'")); + }); + }); +} + +class FakePermissionHandlerPlatform extends PermissionHandlerPlatform { + FakePermissionHandlerPlatform({ + required this.statuses, + required this.requestResults, + }); + + final Map statuses; + final Map requestResults; + final List> requestCalls = >[]; + + @override + Future checkPermissionStatus(Permission permission) async { + return statuses[permission] ?? PermissionStatus.denied; + } + + @override + Future checkServiceStatus(Permission permission) async { + return ServiceStatus.enabled; + } + + @override + Future openAppSettings() async { + return true; + } + + @override + Future> requestPermissions( + List permissions, + ) async { + requestCalls.add(List.unmodifiable(permissions)); + return { + for (final permission in permissions) + permission: requestResults[permission] ?? PermissionStatus.granted, + }; + } + + @override + Future shouldShowRequestPermissionRationale( + Permission permission, + ) async { + return false; + } +} diff --git a/test/features/recording/recording_platform_test.dart b/test/features/recording/recording_platform_test.dart new file mode 100644 index 0000000..8b02c1b --- /dev/null +++ b/test/features/recording/recording_platform_test.dart @@ -0,0 +1,21 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_template/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, + ); + }); + }); +}