Add lead workflows, PDF signing, AR projects and photo gallery integration
This commit is contained in:
316
GasTankAR/Views/Leads/Detail/LeadPhotoGallerySection.swift
Normal file
316
GasTankAR/Views/Leads/Detail/LeadPhotoGallerySection.swift
Normal file
@ -0,0 +1,316 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user