import SwiftUI import SwiftData struct LeadPhotoGallerySection: View { let photos: [LeadPhoto] let leadID: String let salesCode: String let repository: any LeadDetailRepository let onLogout: () -> Void var refreshID: Int = 0 @Environment(\.modelContext) private var context @State private var localPhotos: [ProjectPhoto] = [] @State private var photoStore: LeadARPhotoStore? @State private var localError: String? private var galleryPhotos: [LeadGalleryPhoto] { localPhotos.map { .local($0) } + photos.map { .remote($0) } } @State private var showsAll = false @State private var isSelecting = false @State private var transfer = DocuframeViewModel() @State private var selected: LeadGalleryPhoto? @State private var selectedInGallery: LeadGalleryPhoto? var body: some View { VStack(alignment: .leading, spacing: 8) { HStack { LeadSectionHeading(title: "FOTOGALERIE") Spacer() if !galleryPhotos.isEmpty { Button("Alle \(galleryPhotos.count) anzeigen") { showsAll = true } .font(.subheadline).padding(.trailing, 16) } } VStack(spacing: 0) { if let localError { Text(localError).font(.footnote).foregroundStyle(.secondary) Button("Erneut versuchen") { loadLocalPhotos() } } if galleryPhotos.isEmpty { ContentUnavailableView( "Noch keine Fotos", systemImage: "photo.on.rectangle", description: Text("Fotos können über den Kamera-Button im AR-Modus aufgenommen werden.") ) } else { ScrollView(.horizontal) { HStack(spacing: 12) { ForEach(Array(galleryPhotos.prefix(4))) { photo in thumbnail(photo).frame(width: 180, height: 120) } } } .scrollIndicators(.hidden) } } .padding(16) .frame(maxWidth: .infinity) .background(LeadDetailStyle.card, in: .rect(cornerRadius: 12)) } .task(id: refreshID) { loadLocalPhotos() } .sheet(isPresented: $showsAll, onDismiss: { isSelecting = false transfer.selectedIDs.removeAll() transfer.result = nil if let photo = selectedInGallery { selectedInGallery = nil selected = photo } }) { NavigationStack { ScrollView { LazyVGrid(columns: [GridItem(.adaptive(minimum: 220))], spacing: 16) { ForEach(galleryPhotos) { photo in thumbnail(photo, inGallery: true).frame(height: 160) } } .padding() } .safeAreaInset(edge: .bottom) { if isSelecting { transferBar } } .interactiveDismissDisabled(transfer.isProcessing) .alert(transfer.result?.title ?? "DOCUframe", isPresented: Binding( get: { showsAll && transfer.result != nil }, set: { if !$0 && showsAll { transfer.result = nil } } )) { Button("OK", role: .cancel) { transfer.result = nil } } message: { Text(transfer.result?.message ?? "") } .navigationTitle("Fotogalerie") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .primaryAction) { selectionButton } ToolbarItem(placement: .cancellationAction) { Button(role: .cancel) { showsAll = false } .labelStyle(.iconOnly) .disabled(transfer.isProcessing) } } } } .navigationDestination(isPresented: Binding( get: { selected != nil }, set: { if !$0 { selected = nil } } )) { if let photo = selected { photoScreen(photo) } } } private var selectionButton: some View { Button(isSelecting ? "Abbrechen" : "Auswählen") { isSelecting.toggle() transfer.selectedIDs.removeAll() } .disabled(localPhotos.isEmpty || transfer.isProcessing) } private func isSelected(_ photo: LeadGalleryPhoto) -> Bool { if case .local(let local) = photo { return transfer.isSelected(local) } return false } private var transferBar: some View { VStack(spacing: 10) { Text("AR-Aufnahmen zum Übertragen auswählen") .font(.footnote).foregroundStyle(.secondary) Button { Task { await transfer.upload(from: localPhotos, leadID: leadID) } } label: { HStack { if transfer.isProcessing { ProgressView() } Label("\(transfer.selectionCount) Fotos an DOCUframe senden", systemImage: "arrow.up.doc") } .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) .controlSize(.large) .disabled(!transfer.hasSelection || transfer.isProcessing) Text("Demo: Der DOCUframe-Versand wird derzeit simuliert.") .font(.caption).foregroundStyle(.secondary) } .padding() .background(.bar) } private func loadLocalPhotos() { do { let store = LeadARPhotoStore(container: context.container) localPhotos = try store.photos(for: leadID) photoStore = store transfer.selectedIDs.formIntersection(Set(localPhotos.map(\.persistentModelID))) if localPhotos.isEmpty { isSelecting = false } localError = nil } catch { localError = "AR-Aufnahmen konnten nicht geladen werden." } } @ViewBuilder private func photoImage(_ photo: LeadGalleryPhoto, original: Bool) -> some View { switch photo { case .remote(let remote): LeadPhotoImage(photo: remote, original: original, leadID: leadID, salesCode: salesCode, repository: repository, onLogout: onLogout) case .local(let local): if original { if let image = UIImage(data: local.annotatedImageData ?? local.imageData) { Image(uiImage: image).resizable().scaledToFit() .frame(maxWidth: .infinity, maxHeight: .infinity) } else { ContentUnavailableView("Foto nicht verfügbar", systemImage: "photo.badge.exclamationmark") } } else { PhotoThumbnail(imageData: local.annotatedImageData ?? local.imageData) } } } private func thumbnail(_ photo: LeadGalleryPhoto, inGallery: Bool = false) -> some View { Button { if inGallery && isSelecting { if case .local(let local) = photo { transfer.toggleSelection(local) } } else if inGallery { selectedInGallery = photo showsAll = false } else { selected = photo } } label: { photoImage(photo, original: false) .overlay(alignment: .topLeading) { if photo.isAR { Label("AR", systemImage: "arkit") .font(.caption.bold()).padding(6) .background(.thinMaterial, in: .capsule).padding(8) } } .overlay(alignment: .topTrailing) { if inGallery && isSelecting, case .local(let local) = photo { Image(systemName: transfer.isSelected(local) ? "checkmark.circle.fill" : "circle") .font(.title2) .foregroundStyle(.white) .padding(6) .background(transfer.isSelected(local) ? Color.accentColor : .black.opacity(0.4), in: .circle) .padding(8) } } .clipShape(.rect(cornerRadius: 12)) } .buttonStyle(.plain) .disabled(inGallery && isSelecting && (transfer.isProcessing || !photo.isLocal)) .accessibilityLabel(photo.title) .accessibilityAddTraits(inGallery && isSelecting && isSelected(photo) ? .isSelected : []) } @ViewBuilder private func photoScreen(_ photo: LeadGalleryPhoto) -> some View { switch photo { case .local(let local): if let photoContext = local.modelContext { PhotoViewer(photo: local, photos: localPhotos, usesNavigationStack: false) { current in photoContext.delete(current) do { try photoContext.save() } catch { photoContext.rollback() throw error } } .modelContext(photoContext) .onDisappear { loadLocalPhotos() } } case .remote: photoImage(photo, original: true) .navigationTitle(photo.title) .navigationBarTitleDisplayMode(.inline) } } } private enum LeadGalleryPhoto: Identifiable { case remote(LeadPhoto) case local(ProjectPhoto) enum ID: Hashable { case remote(String) case local(PersistentIdentifier) } var id: ID { switch self { case .remote(let photo): .remote(photo.id) case .local(let photo): .local(photo.persistentModelID) } } var title: String { switch self { case .remote(let photo): photo.title case .local(let photo): "AR-Aufnahme · " + photo.creationDate.formatted(.dateTime.day().month().year().hour().minute()) } } var isLocal: Bool { if case .local = self { return true } return false } var isAR: Bool { switch self { case .remote(let photo): photo.isAR case .local: true } } } private struct LeadPhotoImage: View { let photo: LeadPhoto let original: Bool let leadID: String let salesCode: String let repository: any LeadDetailRepository let onLogout: () -> Void @State private var model = LeadFileViewModel() @State private var retryID = 0 var body: some View { ZStack { LeadDetailStyle.card if let url = model.url, let image = UIImage(contentsOfFile: url.path) { Image(uiImage: image).resizable().scaledToFit() } else if let message = model.message { VStack(spacing: 8) { Image(systemName: "photo.badge.exclamationmark") if original { Text(message) if model.needsLogin { Button("Erneut anmelden", action: onLogout) } else if model.allowsRetry { Button("Erneut versuchen") { retryID += 1 } } } else { Text("Foto nicht verfügbar").font(.caption) } } .multilineTextAlignment(.center).padding() } else { ProgressView() } } .task(id: retryID) { await model.load { try await LoadLeadPhotoUseCase(repository: repository) .execute(photo, original: original, leadID: leadID, salesCode: salesCode) } } } }