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,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()
}
}