feat(ios): support taking photos in notes

This commit is contained in:
tianma-if committed 2026-08-10 14:22:03 +08:00
1 parent 8cff6aafce
commit b71dff0e0c
4 files changed
+176 -15

No files matched your search

@@ -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 {
+1 -1
View File
@@ -43,7 +43,7 @@
<true/>
</dict>
<key>NSCameraUsageDescription</key>
<string>EdgeEver only uses the camera if content inside a note explicitly requests it.</string>
<string>EdgeEver needs camera access to take photos and insert them into notes.</string>
<key>NSMicrophoneUsageDescription</key>
<string>EdgeEver only uses the microphone if content inside a note explicitly requests it.</string>
<key>NSPhotoLibraryUsageDescription</key>
@@ -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 {
+1 -1
View File
@@ -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