Files
Gas-Tank-AR-App/GasTankAR/Views/Leads/Detail/LeadPhotoGallerySection.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)
}
}
}
}