feat(ios): support taking photos in notes
This commit is contained in:
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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user