import SwiftUI import PhotosUI import UIKit /// A helper SwiftUI wrapper for UIImagePickerController to select photos or capture via camera. struct ImagePicker: UIViewControllerRepresentable { var sourceType: UIImagePickerController.SourceType = .camera var onImagePicked: (UIImage) -> Void @Environment(\.dismiss) private var dismiss func makeUIViewController(context: Context) -> UIImagePickerController { let picker = UIImagePickerController() picker.sourceType = sourceType picker.delegate = context.coordinator return picker } func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {} func makeCoordinator() -> Coordinator { Coordinator(self) } class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { let parent: ImagePicker init(_ parent: ImagePicker) { self.parent = parent } func imagePickerController( _ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any] ) { if let image = info[.originalImage] as? UIImage { parent.onImagePicked(image) } parent.dismiss() } func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { parent.dismiss() } } } struct PhotoRecordDraft: Identifiable { let id: UUID let image: UIImage var note: String init( id: UUID = UUID(), image: UIImage, note: String = "" ) { self.id = id self.image = image self.note = note } } struct PhotoRecordEditorSheet: View { @Environment(\.dismiss) private var dismiss let timeLabel: String let onSave: ([PhotoRecordDraft], TimelineLocation?) -> Bool @State private var drafts: [PhotoRecordDraft] = [] @State private var recordLocation: TimelineLocation? @State private var selectedLibraryItems: [PhotosPickerItem] = [] @State private var showCamera = false @State private var isLoadingLibrary = false @State private var loadError: String? var body: some View { NavigationStack { ZStack { Color.spaceBlack.ignoresSafeArea() ScrollView { VStack(alignment: .leading, spacing: 16) { Label("记录点 \(timeLabel)", systemImage: "clock") .font(.system(size: 12, weight: .medium, design: .monospaced)) .foregroundStyle(Color.secondary) HStack(spacing: 12) { if UIImagePickerController.isSourceTypeAvailable(.camera) { Button { showCamera = true } label: { photoSourceButtonLabel( title: "拍照", systemImage: "camera" ) } .buttonStyle(.plain) } PhotosPicker( selection: $selectedLibraryItems, maxSelectionCount: 0, matching: .images ) { photoSourceButtonLabel( title: "从相册选择", systemImage: "photo.on.rectangle" ) } .buttonStyle(.plain) } TimelineLocationButton(location: $recordLocation) if isLoadingLibrary { HStack(spacing: 8) { ProgressView() Text("正在读取照片…") .font(.system(size: 12)) .foregroundStyle(Color.secondary) } } if drafts.isEmpty, !isLoadingLibrary { ContentUnavailableView( "还没有照片", systemImage: "photo.badge.plus", description: Text("可以连续拍照,也可以从相册一次选择多张。") ) .frame(maxWidth: .infinity, minHeight: 220) } else { LazyVStack(spacing: 14) { ForEach($drafts) { $draft in photoDraftCard(draft: $draft) } } } } .padding(20) } } .navigationTitle("添加照片记录") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } } ToolbarItem(placement: .confirmationAction) { Button("保存 \(drafts.count) 张") { if onSave(drafts, recordLocation) { dismiss() } } .disabled(drafts.isEmpty || isLoadingLibrary) } } } .fullScreenCover(isPresented: $showCamera) { ImagePicker(sourceType: .camera) { image in drafts.append(PhotoRecordDraft(image: image)) } } .onChange(of: selectedLibraryItems) { _, newItems in guard !newItems.isEmpty else { return } Task { await importLibraryItems(newItems) } } .alert("无法读取照片", isPresented: Binding( get: { loadError != nil }, set: { if !$0 { loadError = nil } } )) { Button("知道了", role: .cancel) {} } message: { Text(loadError ?? "请重新选择照片。") } .presentationDetents([.large]) .presentationDragIndicator(.visible) .presentationBackground(Color.spaceBlack) } private func photoSourceButtonLabel( title: String, systemImage: String ) -> some View { Label(title, systemImage: systemImage) .font(.system(size: 13, weight: .medium)) .foregroundStyle(Color.primary) .frame(maxWidth: .infinity) .padding(.vertical, 12) .background(Color.cardBackground.opacity(0.35)) .businessBorder(cornerRadius: 8) .contentShape(Rectangle()) } private func photoDraftCard( draft: Binding ) -> some View { VStack(alignment: .leading, spacing: 10) { ZStack(alignment: .topTrailing) { Image(uiImage: draft.wrappedValue.image) .resizable() .scaledToFit() .frame(maxWidth: .infinity, minHeight: 160, maxHeight: 300) .clipShape(RoundedRectangle(cornerRadius: 8)) Button(role: .destructive) { let draftID = draft.wrappedValue.id drafts.removeAll { $0.id == draftID } } label: { Image(systemName: "trash") .font(.system(size: 12, weight: .semibold)) .foregroundStyle(.white) .frame(width: 32, height: 32) .background(.black.opacity(0.6), in: Circle()) } .padding(8) .accessibilityLabel("移除这张照片") } ZStack(alignment: .topLeading) { if draft.wrappedValue.note.isEmpty { Text("照片备注") .font(.system(size: 14)) .foregroundStyle(Color.secondary.opacity(0.55)) .padding(.horizontal, 15) .padding(.vertical, 17) .allowsHitTesting(false) } TextEditor(text: draft.note) .font(.system(size: 14)) .foregroundStyle(Color.primary) .scrollContentBackground(.hidden) .padding(8) .frame(height: 72) } .background(Color.cardBackground.opacity(0.45)) .businessBorder(cornerRadius: 8) } .padding(12) .background(Color.cardBackground.opacity(0.18)) .businessBorder(cornerRadius: 10) } @MainActor private func importLibraryItems(_ items: [PhotosPickerItem]) async { isLoadingLibrary = true defer { isLoadingLibrary = false selectedLibraryItems = [] } var importedDrafts: [PhotoRecordDraft] = [] var failedCount = 0 for item in items { do { guard let data = try await item.loadTransferable(type: Data.self), let image = UIImage(data: data) else { failedCount += 1 continue } importedDrafts.append(PhotoRecordDraft(image: image)) } catch { failedCount += 1 } } drafts.append(contentsOf: importedDrafts) if failedCount > 0 { loadError = failedCount == items.count ? "所选照片均无法读取,请重新选择。" : "有 \(failedCount) 张照片无法读取,其余照片已添加。" } } }