Add lead workflows, PDF signing, AR projects and photo gallery integration

This commit is contained in:
Dennis Nemec
2026-11-08 17:49:39 +01:00
parent a343bde741
commit 8d18873412
82 changed files with 6401 additions and 34 deletions

View File

@ -0,0 +1,47 @@
import SwiftUI
import SwiftData
/// Composition boundary: the AR picker receives only neutral project data.
struct LeadARProjectsSheet: View {
let detail: LeadDetail
@Environment(\.modelContext) private var context
@State private var service: LeadARProjectsService?
var body: some View {
Group {
if let service {
LeadARProjectsContent(service: service)
} else {
ProgressView()
}
}
.task {
if service == nil {
service = LeadARProjectsService(detail: detail, container: context.container)
}
}
}
}
private struct LeadARProjectsContent: View {
let service: LeadARProjectsService
@State private var activeProject: Project?
@State private var errorMessage: String?
var body: some View {
ARProjectsSheet(service: service) { id in
do { activeProject = try service.resolveProject(id: id) }
catch { errorMessage = error.localizedDescription }
}
.fullScreenCover(item: $activeProject) { project in
if let context = project.modelContext {
ARExperienceView(project: project).modelContext(context)
}
}
.alert("AR-Projekt konnte nicht geöffnet werden",
isPresented: Binding(get: { errorMessage != nil },
set: { if !$0 { errorMessage = nil } })) {
Button("OK", role: .cancel) { errorMessage = nil }
} message: { Text(errorMessage ?? "") }
}
}

View File

@ -0,0 +1,57 @@
import SwiftUI
struct LeadContactSection: View {
let detail: LeadDetail
var body: some View {
VStack(alignment: .leading, spacing: 8) {
LeadSectionHeading(title: "KONTAKT")
VStack(spacing: 0) {
LeadContactRow(title: "Ansprechpartner", value: detail.contactDescription,
icon: "person.fill", color: .gray)
Divider().padding(.leading, 56)
LeadContactRow(title: "Telefon", value: detail.lead.phoneNumber,
icon: "phone.fill", color: .green)
Divider().padding(.leading, 56)
LeadContactRow(title: "Mobil", value: detail.mobile,
icon: "iphone", color: .green)
Divider().padding(.leading, 56)
LeadContactRow(title: "E-Mail", value: detail.email,
icon: "envelope.fill", color: .blue)
Divider().padding(.leading, 56)
LeadContactRow(title: "Anschrift",
value: [detail.lead.address.street, detail.lead.locality].compactMap { $0 }.joined(separator: "\n"),
icon: "mappin", color: .red)
}
.background(LeadDetailStyle.card, in: .rect(cornerRadius: 12))
}
}
}
private struct LeadContactRow: View {
let title: LocalizedStringKey
let value: String?
let icon: String
let color: Color
@ScaledMetric(relativeTo: .body) private var iconSize: CGFloat = 30
var body: some View {
HStack(spacing: 12) {
Image(systemName: icon)
.font(.body.weight(.semibold))
.foregroundStyle(.white)
.frame(width: iconSize, height: iconSize)
.background(color, in: .rect(cornerRadius: 7))
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 3) {
Text(title).font(.caption).foregroundStyle(.secondary)
Text(value ?? "Nicht hinterlegt")
.font(.body)
.foregroundStyle(value == nil ? .secondary : .primary)
.textSelection(.enabled)
}
Spacer(minLength: 0)
}
.padding(.horizontal, 16).padding(.vertical, 11)
}
}

View File

@ -0,0 +1,74 @@
import SwiftUI
struct LeadDetailHeaderView: View {
let detail: LeadDetail
let canStartAR: Bool
let onStartAR: () -> Void
@Environment(\.openURL) private var openURL
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@State private var actionUnavailable = false
var body: some View {
let layout = dynamicTypeSize.isAccessibilitySize
? AnyLayout(VStackLayout(alignment: .leading, spacing: 16))
: AnyLayout(HStackLayout(spacing: 20))
layout {
HStack(spacing: 18) {
Text(detail.initials)
.font(.title2.weight(.medium))
.frame(width: 64, height: 64)
.background(.gray.gradient, in: .circle)
.foregroundStyle(.white)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 6) {
Text(detail.lead.displayName).font(.title.bold())
Text("\(detail.lead.id) · \(detail.customerType) · \(detail.lead.address.city)")
.font(.subheadline).foregroundStyle(.secondary)
}
}
Spacer(minLength: 0)
HStack(spacing: 24) {
HStack(spacing: 8) {
action("Anrufen", icon: "phone.fill", url: detail.phoneURL)
action("E-Mail", icon: "envelope.fill", url: detail.emailURL)
action("Route", icon: "location.fill", url: detail.routeURL)
}
Button(action: onStartAR) {
VStack(spacing: 8) {
Image(systemName: "arkit").font(.title3)
Text("AR").font(.caption)
}
.frame(minWidth: 60, minHeight: 56)
.padding(6)
.foregroundStyle(.white)
.background(LeadDetailStyle.blue, in: .rect(cornerRadius: 14))
}
.buttonStyle(.plain)
.opacity(canStartAR ? 1 : 0.5)
.disabled(!canStartAR)
.accessibilityLabel("AR-Modus starten")
}
}
.alert("Aktion nicht verfügbar", isPresented: $actionUnavailable) {
Button("OK", role: .cancel) {}
} message: {
Text("Auf diesem Gerät ist keine passende App für diese Aktion verfügbar.")
}
}
private func action(_ title: LocalizedStringKey, icon: String, url: URL?) -> some View {
Button {
guard let url else { return }
openURL(url) { accepted in actionUnavailable = !accepted }
} label: {
VStack(spacing: 8) {
Image(systemName: icon).font(.title3)
Text(title).font(.caption)
}
.frame(minWidth: 60, minHeight: 56)
.padding(6)
.background(LeadDetailStyle.card, in: .rect(cornerRadius: 14))
}
.disabled(url == nil)
}
}

View File

@ -0,0 +1,110 @@
import SwiftUI
enum LeadDetailStyle {
static let blue = LeadOverviewStyle.blue
static let background = Color(uiColor: .systemGroupedBackground)
static let card = Color(uiColor: .secondarySystemGroupedBackground)
}
extension LeadDetail {
var initials: String {
String(lead.displayName.split(separator: " ").prefix(2).compactMap(\.first)).uppercased()
}
var contactDescription: String? {
let value = [contactName, contactRole].compactMap { $0 }.joined(separator: " · ")
return value.isEmpty ? nil : value
}
var phoneURL: URL? {
guard let phone = lead.phoneNumber else { return nil }
let digits = phone.filter { $0.isNumber || $0 == "+" }
return digits.isEmpty ? nil : URL(string: "tel:\(digits)")
}
var emailURL: URL? {
guard let email, !email.isEmpty else { return nil }
var url = URLComponents()
url.scheme = "mailto"
url.path = email
return url.url
}
var routeURL: URL? {
var url = URLComponents()
url.scheme = "https"
url.host = "maps.apple.com"
url.queryItems = [URLQueryItem(name: "daddr", value: lead.fullAddress)]
return url.url
}
}
extension LeadOffer {
var subtitle: String {
let kindName: String
switch kind {
case .purchase: kindName = "Kauf"
case .rental: kindName = "Miete"
case .service: kindName = "Service"
}
let price = amount.formatted(.currency(code: "EUR").locale(Locale(identifier: "de_DE")))
let interval: String
switch billing {
case .once: interval = "netto"
case .monthly: interval = "/ Monat"
case .annually: interval = "/ Jahr"
}
let date = acceptedAt ?? validUntil
let suffix = date.map {
(acceptedAt == nil ? "gültig bis " : "angenommen am ")
+ $0.formatted(.dateTime.day().month().year().locale(Locale(identifier: "de_DE")))
}
return [id, kindName, "\(price) \(interval)", suffix].compactMap { $0 }.joined(separator: " · ")
}
}
struct LeadSectionHeading: View {
let title: LocalizedStringKey
var body: some View {
Text(title).font(.caption).foregroundStyle(.secondary)
.padding(.horizontal, 16)
}
}
struct LeadSignatureProgress: View {
let signed: Int
let total: Int
var body: some View {
if total > 0 && signed == total {
Label("Vollständig", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green).font(.subheadline)
} else {
Text("\(signed) von \(total)").foregroundStyle(.secondary).font(.subheadline)
}
}
}
struct LeadSignButton: View {
let action: () -> Void
var body: some View {
Button(action: action) {
Label("Unterschreiben", systemImage: "signature")
.font(.subheadline.weight(.medium))
}
.buttonStyle(.borderedProminent)
.buttonBorderShape(.capsule)
}
}
extension Error {
var leadFileMessage: LocalizedStringResource {
if let error = self as? LeadError { return error.message }
switch self as? LeadFileError {
case .notFound: return "Diese Datei ist nicht mehr verfügbar."
case .invalidFile: return "Die heruntergeladene Datei ist ungültig und kann nicht geöffnet werden."
case .storageUnavailable: return "Die Datei konnte nicht auf diesem Gerät gespeichert werden."
case nil: return "Die Datei konnte nicht geladen werden. Bitte versuche es erneut."
}
}
}

View File

@ -0,0 +1,98 @@
import SwiftUI
import SwiftData
struct LeadDetailScreen: View {
@State private var model: LeadDetailViewModel
@State private var retryID = 0
@Environment(\.dismiss) private var dismiss
private let leadID: String
private let salesCode: String
private let repository: any LeadDetailRepository
let onLogout: () -> Void
init(leadID: String, salesCode: String, repository: any LeadDetailRepository,
onLogout: @escaping () -> Void) {
self.leadID = leadID
self.salesCode = salesCode
self.repository = repository
_model = State(initialValue: LeadDetailViewModel(
leadID: leadID, salesCode: salesCode, repository: repository
))
self.onLogout = onLogout
}
var body: some View {
@Bindable var model = model
ZStack {
if let error = model.error {
ContentUnavailableView {
Label("Interessent konnte nicht geladen werden", systemImage: "exclamationmark.triangle")
} description: {
Text(error.message)
} actions: {
if error == .unauthorized {
Button("Erneut anmelden", action: onLogout)
} else if error.allowsRetry {
Button("Erneut versuchen") { retryID += 1 }
}
Button("Zur Übersicht") { dismiss() }
}
} else if let detail = model.detail {
LeadDetailView(detail: detail, model: model, salesCode: salesCode,
repository: repository, onLogout: onLogout)
} else {
LeadDetailSkeletonView()
}
}
.navigationTitle("")
.navigationBarTitleDisplayMode(.inline)
.tint(LeadDetailStyle.blue)
.toolbar {
ToolbarItem(placement: .principal) {
HStack(spacing: 8) {
Image("LoginLogo")
.resizable()
.scaledToFit()
.frame(width: 30, height: 30)
.clipShape(.rect(cornerRadius: 8))
.accessibilityHidden(true)
Text("DrachenGas")
.font(.headline)
.foregroundStyle(LeadDetailStyle.blue)
}
.accessibilityElement(children: .combine)
}
if model.detail == nil && model.error == nil {
ToolbarItem(placement: .topBarTrailing) {
ProgressView().accessibilityLabel("Interessent wird geladen")
}
}
}
.sheet(item: $model.selectedDocument) { document in
LeadPDFPreview(document: document, leadID: leadID, salesCode: salesCode,
repository: repository, onLogout: onLogout,
signedURL: model.signedDocumentURLs[document.id])
}
.sheet(item: $model.signingRequest) { request in
LeadSigningSheet(request: request, leadID: leadID, salesCode: salesCode,
repository: repository, onSigned: model.didSign)
}
.modelContainer(for: [Project.self, LeadARProjectLink.self])
.task(id: retryID) { await model.load() }
}
}
#Preview("Interessent", traits: .fixedLayout(width: 1194, height: 834)) {
NavigationStack {
LeadDetailScreen(leadID: "L-240318", salesCode: "MKR",
repository: DefaultLeadDetailRepository(
remoteService: StubLeadDetailRemoteService(delay: .zero)),
onLogout: {})
}
}
#Preview("Ladezustand", traits: .fixedLayout(width: 1194, height: 834)) {
NavigationStack {
LeadDetailSkeletonView().navigationTitle("")
}
}

View File

@ -0,0 +1,53 @@
import SwiftUI
struct LeadDetailSkeletonView: View {
var body: some View {
LeadDetailPageLayout {
HStack(spacing: 18) {
Circle().fill(.quaternary).frame(width: 64, height: 64)
VStack(alignment: .leading, spacing: 10) {
Text("Name des Interessenten").font(.title.bold())
Text("Lead-ID · Kundentyp · Ort").font(.subheadline)
}.redacted(reason: .placeholder)
Spacer()
}
} left: {
VStack(alignment: .leading, spacing: 24) {
LeadSectionHeading(title: "KONTAKT")
VStack(alignment: .leading, spacing: 22) {
ForEach(["Ansprechpartner", "Telefon", "Mobil", "E-Mail", "Anschrift"], id: \.self) { label in
HStack(spacing: 12) {
RoundedRectangle(cornerRadius: 6).fill(.quaternary).frame(width: 30, height: 30)
VStack(alignment: .leading, spacing: 4) {
Text(label).font(.caption)
Text("Daten werden geladen").font(.body).redacted(reason: .placeholder)
}
}
}
}
.padding(16).frame(maxWidth: .infinity, alignment: .leading)
.background(LeadDetailStyle.card, in: .rect(cornerRadius: 12))
LeadSectionHeading(title: "STANDORT")
RoundedRectangle(cornerRadius: 12).fill(.quaternary).frame(height: LeadLocationSection.mapHeight)
}
} right: {
VStack(alignment: .leading, spacing: 16) {
LeadSectionHeading(title: "ANGEBOTE")
ForEach(0..<3) { _ in
VStack(alignment: .leading, spacing: 12) {
Text("Angebot für einen Flüssiggastank").font(.headline)
Text("Angebotsnummer · Preis · Gültigkeit").font(.subheadline)
}
.redacted(reason: .placeholder)
.padding(20).frame(maxWidth: .infinity, alignment: .leading)
.background(LeadDetailStyle.card, in: .rect(cornerRadius: 12))
}
LeadSectionHeading(title: "FOTOGALERIE")
RoundedRectangle(cornerRadius: 12).fill(.quaternary).frame(height: 120)
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("Interessent wird geladen")
.allowsHitTesting(false)
}
}

View File

@ -0,0 +1,68 @@
import SwiftUI
import SwiftData
struct LeadDetailView: View {
let detail: LeadDetail
let model: LeadDetailViewModel
let salesCode: String
let repository: any LeadDetailRepository
let onLogout: () -> Void
@State private var showsARProjects = false
@State private var galleryRevision = 0
var body: some View {
LeadDetailPageLayout {
LeadDetailHeaderView(detail: detail, canStartAR: true, onStartAR: { showsARProjects = true })
} left: {
VStack(spacing: 28) {
LeadContactSection(detail: detail)
LeadLocationSection(name: detail.lead.displayName, address: detail.lead.address)
}
} right: {
VStack(spacing: 28) {
LeadOffersSection(offers: detail.offers, model: model)
LeadPhotoGallerySection(photos: detail.photos, leadID: detail.lead.id,
salesCode: salesCode, repository: repository, onLogout: onLogout,
refreshID: galleryRevision)
}
}
.sheet(isPresented: $showsARProjects, onDismiss: { galleryRevision += 1 }) {
LeadARProjectsSheet(detail: detail)
}
}
}
struct LeadDetailPageLayout<Header: View, Left: View, Right: View>: View {
let header: Header
let left: Left
let right: Right
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
init(@ViewBuilder header: () -> Header, @ViewBuilder left: () -> Left,
@ViewBuilder right: () -> Right) {
self.header = header()
self.left = left()
self.right = right()
}
var body: some View {
GeometryReader { geometry in
ScrollView {
VStack(alignment: .leading, spacing: 28) {
header
if geometry.size.width >= 900 && !dynamicTypeSize.isAccessibilitySize {
HStack(alignment: .top, spacing: 24) {
left.frame(width: (geometry.size.width - 72) * 0.34)
right.frame(maxWidth: .infinity)
}
} else {
left
right
}
}
.padding(24)
}
.background(LeadDetailStyle.background.ignoresSafeArea())
}
}
}

View File

@ -0,0 +1,106 @@
import Foundation
import Observation
@MainActor
@Observable
final class LeadDetailViewModel {
private(set) var detail: LeadDetail?
private(set) var error: LeadError?
var expandedOffers: Set<String> = []
var expandedGroups: Set<String> = []
var selectedDocument: LeadDocument?
var signingRequest: LeadSigningRequest?
private(set) var signedDocumentURLs: [String: URL] = [:]
private let leadID: String
private let salesCode: String
private let getDetail: GetLeadDetailUseCase
private var generation = 0
init(leadID: String, salesCode: String, repository: any LeadDetailRepository) {
self.leadID = leadID
self.salesCode = salesCode
self.getDetail = GetLeadDetailUseCase(repository: repository)
}
var lead: Lead? { detail?.lead }
var isAllExpanded: Bool {
guard let detail, !detail.offers.isEmpty else { return false }
return Set(detail.offers.map(\.id)).isSubset(of: expandedOffers)
&& Set(detail.offers.flatMap(\.groups).map(\.id)).isSubset(of: expandedGroups)
}
func load() async {
generation += 1
let request = generation
detail = nil
error = nil
do {
let result = try await getDetail.execute(id: leadID, salesCode: salesCode)
try Task.checkCancellation()
guard request == generation else { return }
detail = result
let documents = result.offers.flatMap(\.documents)
signedDocumentURLs = LeadSignedDocumentStore().savedURLs(
documents: documents, leadID: leadID, salesCode: salesCode
)
for document in documents {
if let url = signedDocumentURLs[document.id] { didSign(document, url: url) }
}
if let first = result.offers.first {
expandedOffers = [first.id]
expandedGroups = Set(first.groups.filter { !$0.isComplete }.map(\.id))
} else {
expandedOffers = []
expandedGroups = []
}
} catch is CancellationError {
} catch {
guard request == generation, !Task.isCancelled else { return }
self.error = error as? LeadError ?? .unexpected
}
}
func startSigning(offer: LeadOffer, group: LeadDocumentGroup? = nil) {
let documents = group?.documents ?? offer.documents
guard documents.contains(where: { $0.status == .pending }) else { return }
signingRequest = LeadSigningRequest(
offerID: offer.id,
signerName: detail?.contactName ?? detail?.lead.displayName ?? "Kunde",
documents: documents
)
}
func didSign(_ document: LeadDocument, url: URL) {
signedDocumentURLs[document.id] = url
guard var detail else { return }
for offerIndex in detail.offers.indices {
for groupIndex in detail.offers[offerIndex].groups.indices {
for documentIndex in detail.offers[offerIndex].groups[groupIndex].documents.indices {
if detail.offers[offerIndex].groups[groupIndex].documents[documentIndex].id == document.id {
detail.offers[offerIndex].groups[groupIndex].documents[documentIndex].status = .signed
}
}
}
}
self.detail = detail
}
func toggleOffer(_ id: String) {
if !expandedOffers.insert(id).inserted { expandedOffers.remove(id) }
}
func toggleGroup(_ id: String) {
if !expandedGroups.insert(id).inserted { expandedGroups.remove(id) }
}
func toggleAll() {
guard let detail else { return }
if isAllExpanded {
expandedOffers = []
expandedGroups = []
} else {
expandedOffers = Set(detail.offers.map(\.id))
expandedGroups = Set(detail.offers.flatMap(\.groups).map(\.id))
}
}
}

View File

@ -0,0 +1,33 @@
import Foundation
import Observation
@MainActor
@Observable
final class LeadFileViewModel {
private(set) var url: URL?
private(set) var message: LocalizedStringResource?
private(set) var needsLogin = false
private(set) var allowsRetry = true
private var generation = 0
func load(using download: () async throws -> URL) async {
generation += 1
let request = generation
url = nil
message = nil
needsLogin = false
allowsRetry = true
do {
let result = try await download()
try Task.checkCancellation()
guard request == generation else { return }
url = result
} catch is CancellationError {
} catch {
guard request == generation, !Task.isCancelled else { return }
message = error.leadFileMessage
needsLogin = error as? LeadError == .unauthorized
allowsRetry = (error as? LeadError)?.allowsRetry ?? true
}
}
}

View File

@ -0,0 +1,91 @@
import SwiftUI
import MapKit
struct LeadLocationSection: View {
let name: String
let address: LeadAddress
var locationService: any CustomerLocationService = AppleMapsCustomerLocationService()
@State private var coordinate: CLLocationCoordinate2D?
@State private var position: MapCameraPosition = .automatic
@State private var errorMessage: LocalizedStringResource?
@State private var retryID = 0
static let mapHeight: CGFloat = 328
private var addressText: String {
[address.street, "\(address.postalCode) \(address.city)"]
.compactMap { $0 }
.joined(separator: ", ")
}
private var mapsURL: URL? {
var components = URLComponents(string: "https://maps.apple.com/")
components?.queryItems = [URLQueryItem(name: "q", value: addressText)]
return components?.url
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
LeadSectionHeading(title: "STANDORT")
ZStack {
LeadDetailStyle.card
if let coordinate {
Map(position: $position) {
Marker(name, coordinate: coordinate)
.tint(LeadDetailStyle.blue)
}
.mapStyle(.standard(pointsOfInterest: .excludingAll))
.accessibilityLabel("Standort von \(name), \(addressText)")
} else if let errorMessage {
ContentUnavailableView {
Label("Standort nicht verfügbar", systemImage: "mappin.slash")
} description: {
Text(errorMessage)
} actions: {
Button("Erneut versuchen") { retryID += 1 }
}
} else {
ProgressView("Standort wird geladen")
}
}
.safeAreaInset(edge: .bottom, spacing: 0) {
if let mapsURL {
Link(destination: mapsURL) {
Label("In Apple Maps öffnen", systemImage: "map")
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity, minHeight: 48)
.foregroundStyle(.white)
.background(LeadDetailStyle.blue)
}
.buttonStyle(.plain)
}
}
.frame(maxWidth: .infinity)
.frame(height: Self.mapHeight)
.clipShape(.rect(cornerRadius: 12))
}
.task(id: addressText + "#\(retryID)") {
coordinate = nil
errorMessage = nil
do {
let result = try await locationService.coordinate(for: addressText)
try Task.checkCancellation()
position = .region(MKCoordinateRegion(
center: result, latitudinalMeters: 800, longitudinalMeters: 800
))
coordinate = result
} catch is CancellationError {
} catch {
guard !Task.isCancelled else { return }
if error is CustomerLocationError {
errorMessage = "Für diese Anschrift wurde kein Standort gefunden."
} else if let networkError = error as? URLError,
[.notConnectedToInternet, .networkConnectionLost, .timedOut].contains(networkError.code) {
errorMessage = "Keine Verbindung zu Apple Maps. Bitte prüfe deine Internetverbindung und versuche es erneut."
} else {
errorMessage = "Apple Maps konnte den Standort gerade nicht ermitteln. Bitte versuche es erneut."
}
}
}
}
}

View File

@ -0,0 +1,192 @@
import SwiftUI
struct LeadOffersSection: View {
let offers: [LeadOffer]
@Environment(\.accessibilityReduceMotion) private var reduceMotion
let model: LeadDetailViewModel
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
LeadSectionHeading(title: "ANGEBOTE")
Spacer()
Button(model.isAllExpanded ? "Alle zuklappen" : "Alle aufklappen") {
withAnimation(reduceMotion ? nil : .smooth(duration: 0.32)) {
model.toggleAll()
}
}
.font(.subheadline)
.disabled(offers.isEmpty)
.padding(.trailing, 16)
}
if offers.isEmpty {
ContentUnavailableView("Noch keine Angebote", systemImage: "doc.text",
description: Text("Für diesen Interessenten liegen noch keine Angebote vor."))
.frame(maxWidth: .infinity)
.background(LeadDetailStyle.card, in: .rect(cornerRadius: 12))
} else {
VStack(spacing: 0) {
ForEach(offers) { offer in
LeadOfferRow(offer: offer, model: model)
if offer.id != offers.last?.id { Divider().padding(.leading, 44) }
}
}
.background(LeadDetailStyle.card, in: .rect(cornerRadius: 12))
}
}
}
}
struct LeadOfferRow: View {
let offer: LeadOffer
let model: LeadDetailViewModel
var body: some View {
VStack(spacing: 0) {
LeadTreeHeading(title: offer.title, subtitle: offer.subtitle, icon: "doc.on.doc.fill",
expanded: model.expandedOffers.contains(offer.id),
signed: offer.signedCount, total: offer.documents.count, prominent: true,
onSign: { model.startSigning(offer: offer) }) {
model.toggleOffer(offer.id)
}
LeadTreeDisclosure(expanded: model.expandedOffers.contains(offer.id)) {
ForEach(offer.groups) { group in
Divider().padding(.leading, 44)
LeadDocumentGroupRow(group: group, offer: offer, model: model).padding(.leading, 24)
}
}
}
}
}
struct LeadDocumentGroupRow: View {
let group: LeadDocumentGroup
let offer: LeadOffer
let model: LeadDetailViewModel
var body: some View {
VStack(spacing: 0) {
LeadTreeHeading(title: group.title,
subtitle: "\(group.documents.count) \(group.documents.count == 1 ? "PDF" : "PDFs")",
icon: "folder.fill", expanded: model.expandedGroups.contains(group.id),
signed: group.signedCount, total: group.documents.count, prominent: false,
onSign: { model.startSigning(offer: offer, group: group) }) {
model.toggleGroup(group.id)
}
LeadTreeDisclosure(expanded: model.expandedGroups.contains(group.id)) {
ForEach(group.documents) { document in
Divider().padding(.leading, 70)
LeadDocumentRow(document: document) { model.selectedDocument = document }
.padding(.leading, 50)
}
}
}
}
}
private struct LeadTreeDisclosure<Content: View>: View {
let expanded: Bool
@ViewBuilder let content: Content
var body: some View {
// Keep the natural content height while animating the clipped container.
VStack(spacing: 0) {
content
}
.fixedSize(horizontal: false, vertical: true)
.frame(height: expanded ? nil : 0, alignment: .top)
.clipped()
.allowsHitTesting(expanded)
.accessibilityHidden(!expanded)
}
}
private struct LeadTreeHeading: View {
let title: String
let subtitle: String
let icon: String
let expanded: Bool
let signed: Int
let total: Int
let prominent: Bool
let onSign: () -> Void
let onToggle: () -> Void
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
var body: some View {
let layout = dynamicTypeSize.isAccessibilitySize
? AnyLayout(VStackLayout(alignment: .leading, spacing: 10))
: AnyLayout(HStackLayout(spacing: 12))
layout {
Button {
withAnimation(reduceMotion ? nil : .smooth(duration: 0.32)) {
onToggle()
}
} label: {
HStack(spacing: 14) {
Image(systemName: "chevron.right")
.rotationEffect(.degrees(expanded ? 90 : 0))
.font(.caption.weight(.semibold)).frame(width: 12)
.foregroundStyle(LeadDetailStyle.blue)
Image(systemName: icon).font(.title3).frame(width: 22)
.foregroundStyle(LeadDetailStyle.blue)
VStack(alignment: .leading, spacing: 4) {
Text(title).font(prominent ? .headline : .body)
.foregroundStyle(.primary).fixedSize(horizontal: false, vertical: true)
Text(subtitle).font(.caption).foregroundStyle(.secondary)
.lineLimit(2)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.contentShape(.rect)
.frame(minHeight: 44)
}
.buttonStyle(.plain)
.accessibilityLabel(title)
.accessibilityValue(expanded ? "Aufgeklappt" : "Zugeklappt")
.accessibilityHint("Zum Auf- oder Zuklappen doppeltippen")
LeadSignatureProgress(signed: signed, total: total)
.fixedSize(horizontal: !dynamicTypeSize.isAccessibilitySize, vertical: true)
if signed < total { LeadSignButton(action: onSign) }
}
.padding(.horizontal, 16).padding(.vertical, 10)
}
}
struct LeadDocumentRow: View {
let document: LeadDocument
let onOpen: () -> Void
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
var body: some View {
Button(action: onOpen) {
HStack(spacing: 14) {
Image(systemName: "doc.fill").foregroundStyle(.red)
.font(.title3).frame(width: 22)
VStack(alignment: .leading, spacing: 4) {
Text(document.title).foregroundStyle(.primary)
.fixedSize(horizontal: false, vertical: true)
Text("PDF · \(document.pageCount) \(document.pageCount == 1 ? "Seite" : "Seiten")")
.font(.caption).foregroundStyle(.secondary)
if dynamicTypeSize.isAccessibilitySize { status }
}
Spacer(minLength: 4)
if !dynamicTypeSize.isAccessibilitySize { status }
Image(systemName: "chevron.right").font(.caption).foregroundStyle(.tertiary)
}
.padding(.horizontal, 16).padding(.vertical, 11)
.frame(minHeight: 48)
.contentShape(.rect)
}
.buttonStyle(.plain)
.accessibilityHint("PDF öffnen")
}
private var status: some View {
Label(document.status == .signed ? "Unterschrieben" : "Offen",
systemImage: document.status == .signed ? "checkmark.circle.fill" : "circle.fill")
.font(.caption).foregroundStyle(document.status == .signed ? .green : .orange)
.fixedSize()
}
}

View File

@ -0,0 +1,62 @@
import SwiftUI
import PDFKit
struct LeadPDFPreview: View {
let document: LeadDocument
let leadID: String
let salesCode: String
let repository: any LeadDetailRepository
let onLogout: () -> Void
var signedURL: URL? = nil
@State private var model = LeadFileViewModel()
@State private var retryID = 0
@State private var showsPreview = false
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
ZStack {
if let url = signedURL ?? model.url {
LeadPDFView(url: url)
.fullScreenCover(isPresented: $showsPreview) {
LeadDocumentPreview(url: url)
}
} else if let message = model.message {
ContentUnavailableView {
Label("PDF konnte nicht geöffnet werden", systemImage: "doc.badge.ellipsis")
} description: { Text(message) } actions: {
if model.needsLogin {
Button("Erneut anmelden", action: onLogout)
} else if model.allowsRetry {
Button("Erneut versuchen") { retryID += 1 }
}
}
} else {
ProgressView("PDF wird heruntergeladen …")
}
}
.navigationTitle(document.title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(role: .cancel) { dismiss() }
.labelStyle(.iconOnly)
}
ToolbarItem(placement: .topBarTrailing) {
Button("In Vorschau öffnen", systemImage: "arrow.up.left.and.arrow.down.right") {
showsPreview = true
}
.labelStyle(.iconOnly)
.disabled(signedURL == nil && model.url == nil)
}
}
.task(id: retryID) {
guard signedURL == nil else { return }
await model.load {
try await DownloadLeadDocumentUseCase(repository: repository)
.execute(document, leadID: leadID, salesCode: salesCode)
}
}
}
}
}

View 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)
}
}
}
}