317 lines
12 KiB
Swift
317 lines
12 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|
|
}
|