diff --git a/apps/ios/EdgeEver/Features/Workspace/MemoEditView.swift b/apps/ios/EdgeEver/Features/Workspace/MemoEditView.swift index 131656d1..87894d9a 100644 --- a/apps/ios/EdgeEver/Features/Workspace/MemoEditView.swift +++ b/apps/ios/EdgeEver/Features/Workspace/MemoEditView.swift @@ -1,5 +1,13 @@ +import AVFoundation import SwiftUI +private enum ImagePickerRoute: String, Identifiable { + case camera + case library + + var id: String { rawValue } +} + enum MemoEditMode: Equatable { /// `seed` pre-fills title/body/tags (template / clip-style create). When nil, restores local new-note draft. case create(notebookId: String, seed: CreateMemoSeed? = nil) @@ -26,7 +34,11 @@ struct MemoEditView: View { /// with empty defaults from overwriting a non-empty note via autosave / flush. /// Snapshot of body when edit opened (or last intentional load). Used to reject empty clobbers. @State private var showNotebookPicker = false - @State private var showImagePicker = false + @State private var showImageSourcePicker = false + @State private var imagePickerRoute: ImagePickerRoute? + @State private var showCameraAccessAlert = false + @State private var cameraAccessCanOpenSettings = false + @State private var cameraAccessMessage = "" @State private var showUploadError = false @State private var showTemplatePicker = false @State private var showApplyTemplateConfirm = false @@ -128,23 +140,48 @@ struct MemoEditView: View { .presentationDetents([.height(360), .medium]) .presentationDragIndicator(.hidden) } + .confirmationDialog( + env.preferences.t("添加图片", en: "Add image"), + isPresented: $showImageSourcePicker, + titleVisibility: .visible + ) { + Button(env.preferences.t("拍照", en: "Take photo")) { + scheduleCameraCapture() + } + Button(env.preferences.t("从相册选择", en: "Choose from library")) { + scheduleImagePicker(.library) + } + Button(env.preferences.t("取消", en: "Cancel"), role: .cancel) {} + } message: { + Text(env.preferences.t("直接拍照或选择已有照片", en: "Take a new photo or choose an existing one")) + } // fullScreenCover avoids nested-sheet bugs when MemoEditView itself is already a fullScreenCover. // PHPicker + NSItemProvider (not SwiftUI PhotosPicker/Transferable) is the reliable path. - .fullScreenCover(isPresented: $showImagePicker) { - SystemImagePicker { result in - showImagePicker = false - switch result { - case .cancelled: - break - case .failed(let message): - error = message - showUploadError = true - case .picked(let data, let filename): - Task { await insertImageData(data, filename: filename) } + .fullScreenCover(item: $imagePickerRoute) { route in + Group { + switch route { + case .camera: + SystemCameraPicker(onFinish: handleImagePickerResult) + case .library: + SystemImagePicker(onFinish: handleImagePickerResult) } } .ignoresSafeArea() } + .alert( + env.preferences.t("无法使用相机", en: "Unable to use camera"), + isPresented: $showCameraAccessAlert + ) { + Button(env.preferences.t("取消", en: "Cancel"), role: .cancel) {} + if cameraAccessCanOpenSettings { + Button(env.preferences.t("前往设置", en: "Open settings")) { + guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + UIApplication.shared.open(url) + } + } + } message: { + Text(cameraAccessMessage) + } .alert( env.preferences.t("图片上传失败", en: "Image upload failed"), isPresented: $showUploadError @@ -362,7 +399,7 @@ struct MemoEditView: View { for: nil ) error = nil - showImagePicker = true + showImageSourcePicker = true }, onSearchResult: nil, onBodyReady: { @@ -410,6 +447,82 @@ struct MemoEditView: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } + // MARK: - Image source + + private func scheduleImagePicker(_ route: ImagePickerRoute) { + Task { @MainActor in + // Let the confirmation dialog finish dismissing before presenting UIKit. + try? await Task.sleep(nanoseconds: 150_000_000) + guard !Task.isCancelled else { return } + imagePickerRoute = route + } + } + + private func scheduleCameraCapture() { + Task { @MainActor in + // Permission prompts and full-screen covers must not race the source dialog dismissal. + try? await Task.sleep(nanoseconds: 150_000_000) + guard !Task.isCancelled else { return } + beginCameraCapture() + } + } + + @MainActor + private func beginCameraCapture() { + let status = AVCaptureDevice.authorizationStatus(for: .video) + switch CameraCaptureAccess.nextStep( + isCameraAvailable: SystemCameraPicker.isAvailable, + authorizationStatus: status + ) { + case .openCamera: + imagePickerRoute = .camera + case .requestPermission: + Task { + let granted = await AVCaptureDevice.requestAccess(for: .video) + await MainActor.run { + if granted { + imagePickerRoute = .camera + } else { + showCameraSettingsAlert() + } + } + } + case .showSettings: + showCameraSettingsAlert() + case .unavailable: + cameraAccessCanOpenSettings = false + cameraAccessMessage = env.preferences.t( + "此设备没有可用相机。", + en: "This device does not have an available camera." + ) + showCameraAccessAlert = true + } + } + + @MainActor + private func showCameraSettingsAlert() { + cameraAccessCanOpenSettings = true + cameraAccessMessage = env.preferences.t( + "请前往系统设置,允许 EdgeEver 使用相机后再试。", + en: "Open system settings and allow EdgeEver to use the camera, then try again." + ) + showCameraAccessAlert = true + } + + @MainActor + private func handleImagePickerResult(_ result: ImagePickerResult) { + imagePickerRoute = nil + switch result { + case .cancelled: + break + case .failed(let message): + error = message + showUploadError = true + case .picked(let data, let filename): + Task { await insertImageData(data, filename: filename) } + } + } + // MARK: - Derived state private var isCreate: Bool { diff --git a/apps/ios/EdgeEver/Resources/Info.plist b/apps/ios/EdgeEver/Resources/Info.plist index f745b3a6..9274a0fc 100644 --- a/apps/ios/EdgeEver/Resources/Info.plist +++ b/apps/ios/EdgeEver/Resources/Info.plist @@ -43,7 +43,7 @@ NSCameraUsageDescription - EdgeEver only uses the camera if content inside a note explicitly requests it. + EdgeEver needs camera access to take photos and insert them into notes. NSMicrophoneUsageDescription EdgeEver only uses the microphone if content inside a note explicitly requests it. NSPhotoLibraryUsageDescription diff --git a/apps/ios/Tests/EdgeEverTests/ChromeParityTests.swift b/apps/ios/Tests/EdgeEverTests/ChromeParityTests.swift index 8fc4c6aa..2c1c9e7f 100644 --- a/apps/ios/Tests/EdgeEverTests/ChromeParityTests.swift +++ b/apps/ios/Tests/EdgeEverTests/ChromeParityTests.swift @@ -1,3 +1,4 @@ +import AVFoundation import XCTest @testable import EdgeEver @@ -228,6 +229,53 @@ final class ChromeParityTests: XCTestCase { XCTAssertEqual(DetailMemoChrome.syncStatus, "detailSyncStatus") } + // MARK: - Image source parity + + func testCameraAccessDecisionCoversEveryPermissionState() { + XCTAssertEqual( + CameraCaptureAccess.nextStep(isCameraAvailable: false, authorizationStatus: .authorized), + .unavailable + ) + XCTAssertEqual( + CameraCaptureAccess.nextStep(isCameraAvailable: true, authorizationStatus: .authorized), + .openCamera + ) + XCTAssertEqual( + CameraCaptureAccess.nextStep(isCameraAvailable: true, authorizationStatus: .notDetermined), + .requestPermission + ) + XCTAssertEqual( + CameraCaptureAccess.nextStep(isCameraAvailable: true, authorizationStatus: .denied), + .showSettings + ) + XCTAssertEqual( + CameraCaptureAccess.nextStep(isCameraAvailable: true, authorizationStatus: .restricted), + .showSettings + ) + } + + func testCameraFilenameIsStableAndUploadFriendly() { + let date = Date(timeIntervalSince1970: 0) + XCTAssertEqual(ImagePickerData.cameraFilename(at: date), "photo-19700101T000000Z.jpg") + } + + func testCameraCoordinatorSettlesOnlyOnce() { + var callbackCount = 0 + let coordinator = SystemCameraPicker.Coordinator { _ in callbackCount += 1 } + coordinator.finish(.cancelled) + coordinator.finish(.failed("late callback")) + XCTAssertEqual(callbackCount, 1) + } + + func testMemoEditorOffersCameraAndLibrarySources() throws { + let source = try readShippedSource("Features/Workspace/MemoEditView.swift") + XCTAssertTrue(source.contains("showImageSourcePicker = true")) + XCTAssertTrue(source.contains("SystemCameraPicker")) + XCTAssertTrue(source.contains("SystemImagePicker")) + XCTAssertTrue(source.contains("从相册选择")) + XCTAssertTrue(source.contains("拍照")) + } + // MARK: - Helpers private func readShippedSource(_ relativeUnderEdgeEver: String) throws -> String { diff --git a/apps/ios/project.yml b/apps/ios/project.yml index f126870d..d2e4e9bb 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -93,7 +93,7 @@ targets: NSAllowsArbitraryLoads: true NSAllowsArbitraryLoadsInWebContent: true NSPhotoLibraryUsageDescription: EdgeEver needs photo library access to insert images into notes. - NSCameraUsageDescription: EdgeEver only uses the camera if content inside a note explicitly requests it. + NSCameraUsageDescription: EdgeEver needs camera access to take photos and insert them into notes. NSMicrophoneUsageDescription: EdgeEver only uses the microphone if content inside a note explicitly requests it. entitlements: path: EdgeEver/Resources/EdgeEver.entitlements