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,140 @@
import SwiftUI
struct ARProjectCreateView: View {
let isEditing: Bool
let onCreate: (String, Tank, Bool) -> Void
@State private var name = ""
@State private var tank = Tank()
@Environment(\.tankCatalog) private var catalog
init(project: ARProject? = nil, onCreate: @escaping (String, Tank, Bool) -> Void) {
isEditing = project != nil
self.onCreate = onCreate
_name = State(initialValue: project?.name ?? "")
_tank = State(initialValue: project?.tank ?? Tank())
}
private var canCreate: Bool {
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& catalog.definition(for: tank) != nil
}
var body: some View {
Form {
Section {
VStack(spacing: 8) {
ARProjectTankImage(tank: tank)
.frame(height: 150)
.frame(maxWidth: .infinity)
Text("\(tank.size.volumeTitle) · \(tank.placement.displayName) · \(catalog.displayName(for: tank.color))")
.font(.subheadline).foregroundStyle(.secondary)
}
.padding(.vertical, 8)
}
Section("Name") {
TextField("Projektname", text: $name)
.textInputAutocapitalization(.sentences)
}
Section("Tankkonfiguration") {
ARProjectConfigurationView(tank: $tank)
}
}
.navigationTitle(isEditing ? "Projekt bearbeiten" : "Neues Projekt")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button(isEditing ? "Speichern" : "Anlegen") { onCreate(name, tank, false) }
.disabled(!canCreate)
}
}
.safeAreaInset(edge: .bottom) {
Button { onCreate(name, tank, true) } label: {
Label(isEditing ? "Speichern und AR-Modus starten" : "Anlegen und AR-Modus starten", systemImage: "arkit")
.font(.headline).frame(maxWidth: .infinity).padding(.vertical, 10)
}
.buttonStyle(.borderedProminent)
.disabled(!canCreate)
.padding()
.background(.bar)
}
.onAppear { tank = catalog.normalized(tank) }
}
}
struct ARProjectConfigurationView: View {
@Binding var tank: Tank
@Environment(\.tankCatalog) private var catalog
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Volumen").font(.subheadline)
Picker("Volumen", selection: $tank.size) {
ForEach(catalog.sizes(for: tank.placement)) { size in
Text(size.volumeTitle).tag(size)
}
}
.pickerStyle(.segmented)
}
.padding(.vertical, 4)
VStack(alignment: .leading, spacing: 10) {
Text("Aufstellung").font(.subheadline)
Picker("Aufstellung", selection: $tank.placement) {
ForEach(catalog.placements) { placement in
Text(placement.displayName).tag(placement)
}
}
.pickerStyle(.segmented)
}
.padding(.vertical, 4)
HStack {
Text("Farbe")
Spacer()
ForEach(catalog.colors(for: tank.size, placement: tank.placement)) { color in
Button { tank.color = color } label: {
Circle().fill(swatch(color))
.frame(width: 28, height: 28)
.overlay { Circle().strokeBorder(.gray.opacity(0.5), lineWidth: 1) }
.padding(4)
.overlay { Circle().strokeBorder(tank.color == color ? Color.accentColor : .clear, lineWidth: 2) }
.frame(minWidth: 44, minHeight: 44)
}
.buttonStyle(.plain)
.accessibilityLabel(catalog.displayName(for: color))
.accessibilityAddTraits(tank.color == color ? .isSelected : [])
}
Text(catalog.displayName(for: tank.color)).foregroundStyle(.secondary).font(.subheadline)
}
.onChange(of: tank.placement) { _, _ in normalize() }
.onChange(of: tank.size) { _, _ in normalize() }
}
private func normalize() {
tank = catalog.normalized(tank)
tank.showLidOnly = false
}
private func swatch(_ color: TankColor) -> Color {
switch color {
case .white: .white
case .green: Color(red: 0.49, green: 0.65, blue: 0.42)
case .olive: Color(red: 0.37, green: 0.40, blue: 0.22)
}
}
}
#Preview("Projekt konfigurieren") {
NavigationStack {
ARProjectCreateView { _, _, _ in }
}
.frame(width: 600, height: 760)
}
#Preview("Projekt konfigurieren – Dunkel") {
NavigationStack {
ARProjectCreateView { _, _, _ in }
}
.frame(width: 600, height: 760)
.preferredColorScheme(.dark)
}

View File

@ -0,0 +1,31 @@
import SwiftUI
struct ARProjectTankImage: View {
let tank: Tank
@Environment(\.tankCatalog) private var catalog
@State private var image: UIImage?
@State private var failed = false
var body: some View {
Group {
if let image {
Image(uiImage: image).resizable().scaledToFit()
} else if failed {
Image(systemName: "cube.transparent").foregroundStyle(.secondary)
.accessibilityLabel("Tankvorschau nicht verfügbar")
} else {
ProgressView().controlSize(.small)
}
}
.task(id: tank) {
image = nil
failed = false
await Task.yield()
guard !Task.isCancelled else { return }
if let definition = catalog.definition(for: tank) {
image = TankThumbnailRenderer.shared.image(for: definition)
}
failed = image == nil
}
}
}

View File

@ -0,0 +1,202 @@
import SwiftUI
struct ARProjectsSheet: View {
let onOpen: (UUID) -> Void
@State private var model: ARProjectsViewModel
@State private var creating = false
@State private var editing: ARProject?
@Environment(\.dismiss) private var dismiss
init(service: any ARProjectsProviding, onOpen: @escaping (UUID) -> Void) {
self.onOpen = onOpen
_model = State(initialValue: ARProjectsViewModel(service: service))
}
var body: some View {
NavigationStack {
ARProjectListView(supplied: model.supplied,
custom: model.custom, onOpen: onOpen, onDelete: model.delete, onEdit: { editing = $0 }, onCreate: { creating = true })
.overlay { if model.isLoading { ProgressView() } }
.navigationTitle("AR-Projekte")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Abbrechen", systemImage: "xmark") { dismiss() }
.labelStyle(.iconOnly)
}
ToolbarItem(placement: .primaryAction) {
Button("Neues Projekt", systemImage: "plus") { creating = true }
.labelStyle(.iconOnly)
.buttonStyle(.borderedProminent)
}
}
.navigationDestination(isPresented: $creating) {
ARProjectCreateView { name, tank, start in
guard let project = model.create(name: name, tank: tank) else { return }
creating = false
if start { onOpen(project.id) }
}
}
.navigationDestination(isPresented: Binding(
get: { editing != nil }, set: { if !$0 { editing = nil } }
)) {
if let project = editing {
ARProjectCreateView(project: project) { name, tank, start in
guard let updated = model.update(id: project.id, name: name, tank: tank) else { return }
editing = nil
if start { onOpen(updated.id) }
}
.id(project.id)
}
}
}
.task { model.load() }
.alert("Projektaktion fehlgeschlagen",
isPresented: Binding(get: { model.errorMessage != nil },
set: { if !$0 { model.errorMessage = nil } })) {
Button("OK", role: .cancel) { model.errorMessage = nil }
} message: { Text(model.errorMessage ?? "") }
}
}
struct ARProjectListView: View {
let supplied: [ARProject]
let custom: [ARProject]
let onOpen: (UUID) -> Void
let onDelete: (UUID) -> Void
let onEdit: (ARProject) -> Void
let onCreate: () -> Void
var body: some View {
List {
if !supplied.isEmpty {
Section {
ForEach(supplied) { project in
HStack(spacing: 12) {
ARProjectRow(project: project)
Button { onOpen(project.id) } label: {
Label("AR", systemImage: "arkit")
}
.buttonStyle(.bordered)
.accessibilityLabel("AR-Modus starten: " + project.name)
}
}
} header: { Text("Bereitgestellte Projekte") }
footer: { Text("Diese Projekte werden automatisch anhand der Angebote bereitgestellt.") }
}
Section("Eigene Projekte") {
ForEach(custom) { project in
HStack(spacing: 12) {
Button { onEdit(project) } label: {
ARProjectRow(project: project).contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityHint("Projektkonfiguration bearbeiten")
Button { onOpen(project.id) } label: {
Label("AR", systemImage: "arkit")
}
.buttonStyle(.bordered)
.accessibilityLabel("AR-Modus starten: " + project.name)
Divider()
.frame(height: 24)
.accessibilityHidden(true)
Button { onEdit(project) } label: {
Image(systemName: "chevron.right")
.font(.body.weight(.semibold))
.foregroundStyle(.secondary)
.frame(minWidth: 44, minHeight: 44)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel("Projekt bearbeiten: " + project.name)
}
.swipeActions(edge: .trailing) {
Button("Löschen", systemImage: "trash", role: .destructive) {
onDelete(project.id)
}
.tint(.red)
}
}
Button(action: onCreate) {
Label("Neues Projekt", systemImage: "plus.circle.fill")
.padding(.vertical, 8)
}
}
}
.listStyle(.insetGrouped)
}
}
struct ARProjectRow: View {
let project: ARProject
@Environment(\.tankCatalog) private var catalog
@Environment(\.dynamicTypeSize) private var typeSize
var body: some View {
HStack(spacing: 12) {
ARProjectTankImage(tank: project.tank)
.frame(width: 76, height: 60)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 5) {
Text(project.name).font(.headline)
if typeSize.isAccessibilitySize {
configuration
reference
} else {
HStack(alignment: .firstTextBaseline) {
configuration
Spacer(minLength: 4)
reference
}
}
}
Spacer(minLength: 0)
}
.padding(.vertical, 6)
.accessibilityElement(children: .combine)
}
private var configuration: some View {
Text("\(project.tank.size.volumeTitle) · \(project.tank.placement.displayName) · \(catalog.displayName(for: project.tank.color))")
.font(.caption).foregroundStyle(.secondary)
}
@ViewBuilder
private var reference: some View {
if let reference = project.reference {
Text(reference).font(.caption2).foregroundStyle(.secondary)
}
}
}
#Preview("AR-Projekte") {
ARProjectsSheet(service: ARProjectsPreviewService()) { _ in }
.frame(maxWidth: 600, maxHeight: 700)
}
private struct ARProjectsPreviewService: ARProjectsProviding {
func updateProject(id: UUID, name: String, tank: Tank) throws -> ARProject {
ARProject(id: id, name: name, tank: tank, createdAt: .now, source: .custom, reference: nil)
}
func deleteProject(id: UUID) throws {}
func loadProjects() throws -> [ARProject] {
[
ARProject(id: UUID(), name: "Flüssiggastank 2.700 l oberirdisch", tank: Tank(),
createdAt: .now, source: .supplied, reference: "A-2026-1182"),
ARProject(id: UUID(), name: "Flüssiggastank 4.850 l unterirdisch",
tank: Tank(size: .t2_1, color: .olive, placement: .belowGround),
createdAt: .now, source: .supplied, reference: "A-2026-1197"),
ARProject(id: UUID(), name: "Variante hinter dem Haus", tank: Tank(color: .green),
createdAt: .now, source: .custom, reference: nil)
]
}
func createProject(name: String, tank: Tank) throws -> ARProject {
ARProject(id: UUID(), name: name, tank: tank, createdAt: .now, source: .custom, reference: nil)
}
}

View File

@ -0,0 +1,55 @@
import Foundation
import Observation
@MainActor @Observable
final class ARProjectsViewModel {
private(set) var supplied: [ARProject] = []
private(set) var custom: [ARProject] = []
private(set) var isLoading = true
var errorMessage: String?
private let service: any ARProjectsProviding
init(service: any ARProjectsProviding) {
self.service = service
}
func load() {
isLoading = true
defer { isLoading = false }
do {
let projects = try service.loadProjects()
supplied = projects.filter { $0.source == .supplied }
custom = projects.filter { $0.source == .custom }
} catch { errorMessage = error.localizedDescription }
}
func update(id: UUID, name: String, tank: Tank) -> ARProject? {
do {
let project = try service.updateProject(id: id, name: name, tank: tank)
if let index = custom.firstIndex(where: { $0.id == id }) { custom[index] = project }
return project
} catch {
errorMessage = error.localizedDescription
return nil
}
}
func delete(id: UUID) {
do {
try service.deleteProject(id: id)
supplied.removeAll { $0.id == id }
custom.removeAll { $0.id == id }
} catch { errorMessage = error.localizedDescription }
}
func create(name: String, tank: Tank) -> ARProject? {
do {
let project = try service.createProject(name: name, tank: tank)
custom.insert(project, at: 0)
return project
} catch {
errorMessage = error.localizedDescription
return nil
}
}
}

View File

@ -63,6 +63,7 @@ final class DocuframeViewModel {
successText: String,
action: ([DocuframePhoto], String) async throws -> Void
) async {
guard !isProcessing else { return }
let selected = photos.filter { selectedIDs.contains($0.persistentModelID) }
guard !selected.isEmpty else {
result = .failure(message: DocuframeError.noPhotosSelected.localizedDescription)
@ -85,7 +86,7 @@ final class DocuframeViewModel {
/// Bildet das Persistenz-Model auf ein persistenzunabhängiges Domänen-DTO ab.
private static func makeDTO(_ photo: ProjectPhoto) -> DocuframePhoto {
DocuframePhoto(
imageData: photo.imageData,
imageData: photo.annotatedImageData ?? photo.imageData,
creationDate: photo.creationDate,
latitude: photo.latitude,
longitude: photo.longitude

View File

@ -60,8 +60,8 @@ struct GalleryView: View {
PhotoViewer(
photo: photo,
photos: project.photos.sorted { $0.creationDate > $1.creationDate }
) {
delete(photo)
) { selected in
delete(selected)
}
}
}
@ -158,9 +158,10 @@ private struct DrawingStroke {
var isEraser: Bool
}
private struct PhotoViewer: View {
struct PhotoViewer: View {
let photos: [ProjectPhoto]
let onDelete: () -> Void
let onDelete: (ProjectPhoto) throws -> Void
let usesNavigationStack: Bool
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext
@ -172,19 +173,36 @@ private struct PhotoViewer: View {
@State private var drawingColor = Color.red
@State private var drawingWidth: Double = 0.012
@State private var imageRevision = 0
@State private var deletionError: String?
private var currentPhoto: ProjectPhoto {
photos[currentIndex]
}
init(photo: ProjectPhoto, photos: [ProjectPhoto], onDelete: @escaping () -> Void) {
init(photo: ProjectPhoto, photos: [ProjectPhoto], usesNavigationStack: Bool = true,
onDelete: @escaping (ProjectPhoto) throws -> Void) {
self.photos = photos
self.onDelete = onDelete
self.usesNavigationStack = usesNavigationStack
_currentIndex = State(initialValue: photos.firstIndex(of: photo) ?? 0)
}
var body: some View {
NavigationStack {
Group {
if usesNavigationStack {
NavigationStack { content }
} else {
content
}
}
.alert("Foto konnte nicht gelöscht werden", isPresented: Binding(
get: { deletionError != nil }, set: { if !$0 { deletionError = nil } }
)) {
Button("OK", role: .cancel) { deletionError = nil }
} message: { Text(deletionError ?? "") }
}
private var content: some View {
TabView(selection: $currentIndex) {
ForEach(photos.indices, id: \.self) { index in
photoLayout(for: photos[index])
@ -197,7 +215,7 @@ private struct PhotoViewer: View {
ToolbarItem(placement: .topBarLeading) {
if isDrawing {
EmptyView()
} else {
} else if usesNavigationStack {
Button("Fertig") { dismiss() }
}
}
@ -213,8 +231,10 @@ private struct PhotoViewer: View {
.accessibilityLabel("Zeichnung speichern")
} else {
Button("Löschen", systemImage: "trash", role: .destructive) {
onDelete()
dismiss()
do {
try onDelete(currentPhoto)
dismiss()
} catch { deletionError = error.localizedDescription }
}
}
}
@ -250,7 +270,6 @@ private struct PhotoViewer: View {
}
}
.navigationBarTitleDisplayMode(.inline)
}
}
private func photoLayout(for photo: ProjectPhoto) -> some View {
@ -619,6 +638,6 @@ private struct PhotoLocationCard: View {
#Preview("Foto-Detail") {
let photo = PreviewSupport.samplePhoto()
PhotoViewer(photo: photo, photos: [photo]) {}
PhotoViewer(photo: photo, photos: [photo]) { _ in }
}
#endif

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

View File

@ -0,0 +1,50 @@
import SwiftUI
struct BookmarkedLeadCardView: View {
let lead: Lead
let isBusy: Bool
let onOpen: () -> Void
let onBookmark: () -> Void
var body: some View {
ZStack(alignment: .topTrailing) {
Button(action: onOpen) {
VStack(alignment: .leading, spacing: 12) {
Text(lead.id)
.font(.caption)
.padding(.trailing, 36)
Text(lead.displayName)
.font(.headline)
.fixedSize(horizontal: false, vertical: true)
Text(lead.locality)
.font(.subheadline)
Label(lead.phoneNumber ?? " ", systemImage: "phone.fill")
.font(.subheadline)
.hidden()
.accessibilityHidden(true)
HStack(spacing: 12) {
LeadStatusView(offerCount: lead.offerCount,
unsignedContractCount: lead.unsignedContractCount,
highlighted: true)
.frame(maxWidth: .infinity, alignment: .leading)
Image(systemName: "chevron.forward")
.font(.caption.weight(.semibold))
.foregroundStyle(.white.opacity(0.65))
.accessibilityHidden(true)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(18)
.foregroundStyle(.white)
.background(LeadOverviewStyle.blue, in: .rect(cornerRadius: 18))
.shadow(color: LeadOverviewStyle.blue.opacity(0.18), radius: 8, y: 4)
.contentShape(.rect)
}
.buttonStyle(.plain)
.accessibilityHint("Interessentendetails öffnen")
LeadBookmarkButton(isBookmarked: true, isBusy: isBusy, action: onBookmark)
.padding(6)
}
}
}

View File

@ -0,0 +1,37 @@
import SwiftUI
struct BookmarkedLeadsSection: View {
let leads: [Lead]
let columns: [GridItem]
let flyingLeadID: String?
let cardMotion: Namespace.ID
let isBusy: Bool
let onOpen: (Lead) -> Void
let onBookmark: (Lead) -> Void
var body: some View {
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text("Gemerkt").font(.title2.bold())
Text(leads.count, format: .number)
.foregroundStyle(.secondary)
}
if leads.isEmpty {
Text("Mit dem Lesezeichen kannst du dir Interessenten merken.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
LeadCardGrid(columnCount: columns.count) {
ForEach(leads) { lead in
BookmarkedLeadCardView(lead: lead, isBusy: isBusy,
onOpen: { onOpen(lead) },
onBookmark: { onBookmark(lead) })
.matchedGeometryEffect(id: "flight-\(lead.id)", in: cardMotion)
.opacity(flyingLeadID == lead.id ? 0 : 1)
.transition(.identity)
.zIndex(1)
}
}
}
}
}

View File

@ -0,0 +1,23 @@
import SwiftUI
struct LeadCardFlight {
let lead: Lead
}
/// The moving copy lives above the List so row layout and clipping cannot interrupt its path.
struct LeadCardFlightView: View {
let lead: Lead
let showsBookmark: Bool
var body: some View {
var unbookmarkedLead = lead
unbookmarkedLead.isBookmarked = false
return ZStack(alignment: .top) {
LeadCardView(lead: unbookmarkedLead, isBusy: false, onOpen: {}, onBookmark: {})
.opacity(showsBookmark ? 0 : 1)
BookmarkedLeadCardView(lead: lead, isBusy: false, onOpen: {}, onBookmark: {})
.opacity(showsBookmark ? 1 : 0)
}
.shadow(color: .black.opacity(0.12), radius: 14, y: 8)
}
}

View File

@ -0,0 +1,44 @@
import SwiftUI
/// All card positions are calculated in the same transaction as the bookmark flight.
struct LeadCardGrid: Layout {
let columnCount: Int
var spacing: CGFloat = 16
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let width = proposal.width ?? 310
let frames = frames(width: width, subviews: subviews)
return CGSize(width: width, height: frames.map(\.maxY).max() ?? 0)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize,
subviews: Subviews, cache: inout ()) {
let frames = frames(width: bounds.width, subviews: subviews)
for (index, subview) in subviews.enumerated() {
let frame = frames[index]
subview.place(at: CGPoint(x: bounds.minX + frame.minX, y: bounds.minY + frame.minY),
anchor: .topLeading,
proposal: ProposedViewSize(width: frame.width, height: frame.height))
}
}
private func frames(width: CGFloat, subviews: Subviews) -> [CGRect] {
let columns = max(1, columnCount)
let cardWidth = max(0, (width - CGFloat(columns - 1) * spacing) / CGFloat(columns))
var frames: [CGRect] = []
var rowY: CGFloat = 0
var rowHeight: CGFloat = 0
for (index, subview) in subviews.enumerated() {
let column = index % columns
if column == 0 && index > 0 {
rowY += rowHeight + spacing
rowHeight = 0
}
let size = subview.sizeThatFits(ProposedViewSize(width: cardWidth, height: nil))
frames.append(CGRect(x: CGFloat(column) * (cardWidth + spacing), y: rowY,
width: cardWidth, height: size.height))
rowHeight = max(rowHeight, size.height)
}
return frames
}
}

View File

@ -0,0 +1,75 @@
import SwiftUI
struct LeadCardView: View {
let lead: Lead
let isBusy: Bool
let onOpen: () -> Void
let onBookmark: () -> Void
var body: some View {
ZStack(alignment: .topTrailing) {
Button(action: onOpen) {
VStack(alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 8) {
Text(lead.id)
.font(.caption)
.foregroundStyle(.secondary)
.padding(.trailing, 36)
Text(lead.displayName)
.font(.headline)
.foregroundStyle(.primary)
.fixedSize(horizontal: false, vertical: true)
Text(lead.fullAddress)
.font(.subheadline)
.foregroundStyle(.secondary)
Label(lead.phoneNumber ?? " ", systemImage: "phone.fill")
.font(.subheadline)
.foregroundStyle(LeadOverviewStyle.blue)
.hidden()
.accessibilityHidden(true)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(18)
Divider()
HStack(spacing: 12) {
LeadStatusView(offerCount: lead.offerCount, unsignedContractCount: lead.unsignedContractCount)
.frame(maxWidth: .infinity, alignment: .leading)
Image(systemName: "chevron.forward")
.font(.caption.weight(.semibold))
.foregroundStyle(.tertiary)
.accessibilityHidden(true)
}
.padding(.horizontal, 18)
.padding(.vertical, 12)
.background(Color.primary.opacity(0.025), in: .rect)
}
.background(Color(uiColor: .secondarySystemGroupedBackground), in: .rect(cornerRadius: 18))
.clipShape(.rect(cornerRadius: 18))
.contentShape(.rect)
}
.buttonStyle(.plain)
.accessibilityHint("Interessentendetails öffnen")
LeadBookmarkButton(isBookmarked: lead.isBookmarked, isBusy: isBusy, action: onBookmark)
.padding(6)
}
}
}
struct LeadBookmarkButton: View {
let isBookmarked: Bool
let isBusy: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
Image(systemName: isBookmarked ? "bookmark.fill" : "bookmark")
.foregroundStyle(isBookmarked ? Color.red : Color.secondary)
.frame(width: 44, height: 44)
.contentShape(.rect)
}
.buttonStyle(.plain)
.disabled(isBusy)
.accessibilityLabel(isBookmarked ? "Nicht mehr merken" : "Interessent merken")
}
}

View File

@ -0,0 +1,62 @@
import SwiftUI
struct LeadOverviewHeaderView: View {
let session: LoginSession
let lastUpdated: Date?
let isLoading: Bool
let onRefresh: () -> Void
let onLogout: () -> Void
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
var body: some View {
let layout = dynamicTypeSize.isAccessibilitySize
? AnyLayout(VStackLayout(alignment: .leading, spacing: 12))
: AnyLayout(HStackLayout(spacing: 20))
layout {
HStack(spacing: 8) {
Image("LoginLogo")
.resizable()
.scaledToFit()
.frame(width: 34, height: 34)
.clipShape(.rect(cornerRadius: 8))
.accessibilityHidden(true)
Text("DrachenGas")
.font(.title3.bold())
.foregroundStyle(LeadOverviewStyle.blue)
}
Button(action: onRefresh) {
HStack(spacing: 6) {
Image(systemName: "arrow.clockwise")
if let lastUpdated {
Text("Aktualisiert um \(lastUpdated.formatted(.dateTime.hour().minute().second()))")
} else {
Text("Aktualisieren")
}
}
.font(.subheadline)
.foregroundStyle(.secondary)
}
.disabled(isLoading)
if !dynamicTypeSize.isAccessibilitySize {
Spacer(minLength: 8)
}
Menu {
Button("Abmelden", systemImage: "rectangle.portrait.and.arrow.right", action: onLogout)
} label: {
HStack(spacing: 10) {
if let displayName = session.displayName {
Text(displayName)
}
Text(session.salesCode)
.font(.subheadline.bold())
.padding(10)
.background(LeadOverviewStyle.blue, in: .capsule)
.foregroundStyle(.white)
}
.foregroundStyle(LeadOverviewStyle.blue)
}
.accessibilityLabel("Benutzerkonto \(session.displayName ?? session.salesCode)")
}
.buttonStyle(.plain)
}
}

View File

@ -0,0 +1,69 @@
import SwiftUI
struct LeadOverviewStateView: View {
let error: LeadError?
let isLoading: Bool
let isSearching: Bool
let onRetry: () -> Void
let onReset: () -> Void
let onLogin: () -> Void
var body: some View {
VStack(spacing: 16) {
if isLoading {
ProgressView("Interessenten werden geladen …")
.padding(40)
} else if let error {
ContentUnavailableView {
Label("Interessenten nicht verfügbar", systemImage: "exclamationmark.icloud")
} description: {
Text(error.message)
} actions: {
if error == .unauthorized {
Button("Erneut anmelden", action: onLogin)
} else if error.allowsRetry {
Button("Erneut versuchen", action: onRetry)
}
if isSearching {
Button("Suche zurücksetzen", action: onReset)
}
}
} else {
ContentUnavailableView {
Label(isSearching ? "Keine Interessenten gefunden" : "Noch keine Interessenten",
systemImage: isSearching ? "magnifyingglass" : "person.2")
} description: {
Text(isSearching ? "Passe deine Suche oder den Filter an." : "Dir sind aktuell keine Interessenten zugeordnet.")
} actions: {
if isSearching { Button("Suche zurücksetzen", action: onReset) }
}
}
}
.frame(maxWidth: .infinity)
}
}
struct LeadErrorBanner: View {
let error: LeadError
let onRetry: () -> Void
let onLogin: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Label {
Text(error.message)
} icon: {
Image(systemName: "exclamationmark.triangle")
}
if error == .unauthorized {
Button("Erneut anmelden", action: onLogin)
} else if error.allowsRetry {
Button("Übersicht aktualisieren", action: onRetry)
}
}
.font(.callout)
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(Color.orange.opacity(0.12), in: .rect(cornerRadius: 12))
}
}

View File

@ -0,0 +1,223 @@
import SwiftUI
struct LeadOverviewView: View {
let session: LoginSession
let onLogout: () -> Void
private let repository: any LeadRepository
private let detailRepository: any LeadDetailRepository
@State private var model: LeadOverviewViewModel
@State private var refreshID = 0
@FocusState private var isSearchFocused: Bool
@State private var searchFieldFrame: CGRect = .zero
@State private var isPulledDown = false
@Namespace private var cardMotion
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var pendingBookmark: Lead?
@State private var flight: LeadCardFlight?
@State private var flightShowsBookmark = false
@State private var selectedLead: Lead?
@State private var isShowingDetail = false
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
init(session: LoginSession, repository: any LeadRepository,
detailRepository: (any LeadDetailRepository)? = nil,
onLogout: @escaping () -> Void) {
self.detailRepository = detailRepository ?? DefaultLeadDetailRepository(remoteService: StubLeadDetailRemoteService())
self.repository = repository
self.session = session
self.onLogout = onLogout
_model = State(initialValue: LeadOverviewViewModel(salesCode: session.salesCode, repository: repository))
}
var body: some View {
@Bindable var model = model
GeometryReader { geometry in
let columnCount = dynamicTypeSize.isAccessibilitySize ? 1 : min(3, max(1, Int((geometry.size.width - 48) / 310)))
let columns = Array(repeating: GridItem(.flexible(), spacing: 16, alignment: .top), count: columnCount)
List {
VStack(alignment: .leading, spacing: 24) {
LeadOverviewHeaderView(session: session, lastUpdated: model.lastUpdated,
isLoading: model.isLoading, onRefresh: refresh, onLogout: onLogout)
LeadSearchField(text: $model.searchText, isFocused: $isSearchFocused,
onClear: model.clearSearchText)
.onGeometryChange(for: CGRect.self) { geometry in
geometry.frame(in: .global)
} action: { frame in
searchFieldFrame = frame
}
if let error = model.actionError {
LeadErrorBanner(error: error, onRetry: {
model.dismissActionError()
refresh()
}, onLogin: onLogout)
}
if model.hasCurrentResults && !model.isSessionExpired {
if let error = model.error {
LeadErrorBanner(error: error, onRetry: refresh, onLogin: onLogout)
}
BookmarkedLeadsSection(leads: model.bookmarkedLeads, columns: columns,
flyingLeadID: flight?.lead.id,
cardMotion: cardMotion,
isBusy: pendingBookmark != nil,
onOpen: open, onBookmark: toggleBookmark)
.zIndex(1)
LeadResultsSection(leads: model.leads, columns: columns, selection: $model.selection,
cardMotion: cardMotion,
isBusy: pendingBookmark != nil,
onOpen: open, onBookmark: toggleBookmark)
if model.leads.isEmpty {
LeadOverviewStateView(error: nil, isLoading: false,
isSearching: !model.query.searchText.isEmpty || model.selection == .open,
onRetry: refresh, onReset: model.resetSearch, onLogin: onLogout)
}
} else {
LeadOverviewStateView(error: model.error ?? model.actionError,
isLoading: model.isLoading || (model.error == nil && model.actionError == nil),
isSearching: !model.query.searchText.isEmpty || model.selection == .open,
onRetry: refresh, onReset: model.resetSearch, onLogin: onLogout)
}
}
.padding(24)
.listRowInsets(EdgeInsets())
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.scrollBounceBehavior(.always, axes: .vertical)
.refreshable { [model] in
guard !model.isSessionExpired else { return }
await model.reload()
}
.onScrollGeometryChange(for: Bool.self) { geometry in
geometry.contentOffset.y + geometry.contentInsets.top < -4
} action: { _, pulledDown in
isPulledDown = pulledDown
}
.overlay(alignment: .top) {
if model.isLoading && model.hasCurrentResults && !isPulledDown {
ProgressView()
.accessibilityLabel("Interessenten werden aktualisiert")
.padding(12)
.background(.regularMaterial, in: .circle)
.padding(.top, 4)
.allowsHitTesting(false)
}
}
.scrollDisabled(pendingBookmark != nil)
.overlay(alignment: .topLeading) {
if let flight {
LeadCardFlightView(lead: flight.lead, showsBookmark: flightShowsBookmark)
.matchedGeometryEffect(id: "flight-\(flight.lead.id)", in: cardMotion,
properties: .frame, isSource: false)
.allowsHitTesting(false)
.accessibilityHidden(true)
}
}
.scrollDismissesKeyboard(.interactively)
.background(LeadOverviewStyle.background.ignoresSafeArea())
}
.simultaneousGesture(
SpatialTapGesture(coordinateSpace: .global)
.onEnded { value in
if !searchFieldFrame.contains(value.location) {
isSearchFocused = false
}
}
)
.tint(LeadOverviewStyle.blue)
.navigationTitle("Interessenten")
.toolbar(.hidden, for: .navigationBar)
.task(id: model.query) {
await model.reload(debounce: true, useCachedList: true)
}
.task(id: refreshID) {
guard refreshID > 0 else { return }
await model.reload()
}
.task(id: pendingBookmark?.id) {
guard let lead = pendingBookmark else { return }
guard let updated = await model.saveBookmark(lead), !Task.isCancelled else {
pendingBookmark = nil
return
}
if reduceMotion {
withAnimation(.easeInOut(duration: 0.2)) { model.applyBookmark(updated) }
pendingBookmark = nil
return
}
// Establish a copy at the unchanged source before the single layout transaction.
flightShowsBookmark = lead.isBookmarked
flight = LeadCardFlight(lead: lead)
try? await Task.sleep(for: .milliseconds(20))
guard !Task.isCancelled else {
flight = nil
pendingBookmark = nil
return
}
// Unlock at the animation's logical end, without waiting for the spring's settling tail.
withAnimation(.smooth(duration: 0.55), completionCriteria: .logicallyComplete) {
model.applyBookmark(updated)
flightShowsBookmark = updated.isBookmarked
} completion: {
flight = nil
pendingBookmark = nil
}
}
.navigationDestination(isPresented: $isShowingDetail) {
if let selectedLead {
LeadDetailScreen(leadID: selectedLead.id, salesCode: session.salesCode,
repository: detailRepository, onLogout: onLogout)
.id(selectedLead.id)
.toolbar(.visible, for: .navigationBar)
}
}
}
private func refresh() { refreshID += 1 }
private func open(_ lead: Lead) {
guard pendingBookmark == nil, !isShowingDetail, !model.isSessionExpired else { return }
selectedLead = lead
isShowingDetail = true
}
private func toggleBookmark(_ lead: Lead) {
guard pendingBookmark == nil, !model.isSessionExpired else { return }
pendingBookmark = lead
}
}
#Preview("Übersicht", traits: .fixedLayout(width: 1194, height: 834)) {
NavigationStack {
LeadOverviewView(
session: LoginSession(salesCode: "MKR", displayName: "M. Krämer"),
repository: DefaultLeadRepository(remoteService: StubLeadRemoteService(delay: .zero)),
onLogout: {}
)
}
}
#Preview("Keine Interessenten", traits: .fixedLayout(width: 1194, height: 834)) {
NavigationStack {
LeadOverviewView(
session: LoginSession(salesCode: "MKR", displayName: "M. Krämer"),
repository: DefaultLeadRepository(remoteService: StubLeadRemoteService(delay: .zero, records: [])),
onLogout: {}
)
}
}
#Preview("Serverfehler", traits: .fixedLayout(width: 1194, height: 834)) {
NavigationStack {
LeadOverviewView(
session: LoginSession(salesCode: "MKR"),
repository: DefaultLeadRepository(remoteService: StubLeadRemoteService(scenario: .http(500), delay: .zero)),
onLogout: {}
)
}
}

View File

@ -0,0 +1,173 @@
import Foundation
import Observation
@MainActor
@Observable
final class LeadOverviewViewModel {
var searchText = "" {
didSet {
guard searchText != oldValue else { return }
if query.searchText.isEmpty { restoreCachedList() }
}
}
var selection: LeadOverviewSelection = .newest {
didSet { updateDisplayedLeads() }
}
private(set) var leads: [Lead] = []
private(set) var bookmarkedLeads: [Lead] = []
private(set) var isLoading = false
private(set) var lastUpdated: Date?
private(set) var error: LeadError?
private(set) var actionError: LeadError?
private(set) var bookmarkingIDs: Set<String> = []
private var loadedLeads: [Lead] = []
private var cachedList: [Lead]?
private var cachedListUpdatedAt: Date?
private var displayedQuery: LeadQuery?
private var generation = 0
private var bookmarkRevision = 0
private var confirmedBookmarks: [String: (revision: Int, value: Bool)] = [:]
@ObservationIgnored private let salesCode: String
@ObservationIgnored private let load: LoadLeadsUseCase
@ObservationIgnored private let search: SearchLeadsUseCase
@ObservationIgnored private let bookmark: SetLeadBookmarkUseCase
init(salesCode: String, repository: any LeadRepository) {
self.salesCode = salesCode
load = LoadLeadsUseCase(repository: repository)
search = SearchLeadsUseCase(repository: repository)
bookmark = SetLeadBookmarkUseCase(repository: repository)
}
var query: LeadQuery {
// Only the search term affects the server request; sorting and filtering stay local.
LeadQuery(searchText: searchText).normalized
}
var hasCurrentResults: Bool { displayedQuery == query }
var isSessionExpired: Bool { error == .unauthorized || actionError == .unauthorized }
func reload(debounce: Bool = false, useCachedList: Bool = false) async {
if useCachedList && query.searchText.isEmpty && cachedList != nil && !isSessionExpired {
restoreCachedList()
return
}
generation += 1
let request = generation
let requestedBookmarkRevision = bookmarkRevision
let requestedQuery = query
isLoading = true
error = nil
defer {
if request == generation { isLoading = false }
}
do {
if debounce && !requestedQuery.searchText.isEmpty {
try await Task.sleep(for: .milliseconds(300))
}
try Task.checkCancellation()
let result: [Lead]
if requestedQuery == LeadQuery() {
result = try await load.execute(salesCode: salesCode)
} else {
result = try await search.execute(salesCode: salesCode, query: requestedQuery)
}
try Task.checkCancellation()
// Query comparison also protects the interval before the next .task starts.
guard request == generation, requestedQuery == query else { return }
loadedLeads = result.map { lead in
var lead = lead
if let confirmed = confirmedBookmarks[lead.id],
confirmed.revision > requestedBookmarkRevision {
lead.isBookmarked = confirmed.value
}
return lead
}
updateDisplayedLeads()
displayedQuery = requestedQuery
lastUpdated = Date()
if requestedQuery.searchText.isEmpty {
cachedList = loadedLeads
cachedListUpdatedAt = lastUpdated
}
} catch is CancellationError {
// A superseded search must not replace data or present an error.
} catch {
guard request == generation, requestedQuery == query else { return }
self.error = error as? LeadError ?? .unexpected
}
}
func toggleBookmark(_ lead: Lead) async {
if let updated = await saveBookmark(lead) {
applyBookmark(updated)
}
}
func saveBookmark(_ lead: Lead) async -> Lead? {
guard !bookmarkingIDs.contains(lead.id), !isSessionExpired else { return nil }
bookmarkingIDs.insert(lead.id)
actionError = nil
defer { bookmarkingIDs.remove(lead.id) }
do {
let updated = try await bookmark.execute(!lead.isBookmarked, leadID: lead.id, salesCode: salesCode)
try Task.checkCancellation()
return updated
} catch is CancellationError {
return nil
} catch {
actionError = error as? LeadError ?? .unexpected
return nil
}
}
func applyBookmark(_ updated: Lead) {
bookmarkRevision += 1
confirmedBookmarks[updated.id] = (bookmarkRevision, updated.isBookmarked)
if let index = cachedList?.firstIndex(where: { $0.id == updated.id }) {
cachedList?[index] = updated
}
if let index = loadedLeads.firstIndex(where: { $0.id == updated.id }) {
loadedLeads[index] = updated
updateDisplayedLeads()
}
}
private func updateDisplayedLeads() {
let filtered = selection == .open
? loadedLeads.filter { $0.unsignedContractCount > 0 }
: loadedLeads
leads = filtered.sorted { lhs, rhs in
if selection == .alphabetical {
let order = lhs.displayName.localizedStandardCompare(rhs.displayName)
return order == .orderedSame ? lhs.id < rhs.id : order == .orderedAscending
}
return lhs.createdAt == rhs.createdAt ? lhs.id < rhs.id : lhs.createdAt > rhs.createdAt
}
bookmarkedLeads = leads.filter(\.isBookmarked)
}
func dismissActionError() { actionError = nil }
private func restoreCachedList() {
guard let cachedList, !isSessionExpired else { return }
// Invalidate in-flight searches before publishing the original list.
generation += 1
loadedLeads = cachedList
displayedQuery = LeadQuery()
lastUpdated = cachedListUpdatedAt
error = nil
isLoading = false
updateDisplayedLeads()
}
func clearSearchText() {
searchText = ""
}
func resetSearch() {
clearSearchText()
selection = .newest
}
}

View File

@ -0,0 +1,53 @@
import SwiftUI
enum LeadOverviewStyle {
static let blue = Color(red: 0, green: 0.44, blue: 0.80)
static let successGreen = Color(red: 0.12, green: 0.53, blue: 0.38)
static let background = Color(uiColor: .systemGroupedBackground)
}
/// Formatting shared by the overview and the selected lead's detail screen.
extension Lead {
var displayName: String {
companyName ?? [contact?.title, contact?.firstName, contact?.lastName]
.compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " ")
}
var locality: String { "\(address.postalCode) \(address.city)" }
var fullAddress: String {
[address.street, locality].compactMap { $0 }.joined(separator: ", ")
}
}
extension LeadError {
var message: LocalizedStringResource {
switch self {
case .offline: "Keine Internetverbindung. Bitte prüfe deine Verbindung."
case .timeout: "Der Server antwortet derzeit nicht. Bitte versuche es erneut."
case .serviceUnavailable: "Die Interessenten konnten gerade nicht geladen werden."
case .unauthorized: "Deine Sitzung ist abgelaufen. Bitte melde dich erneut an."
case .forbidden: "Du hast keine Berechtigung für diesen Vorgang."
case .notFound: "Dieser Interessent ist nicht mehr verfügbar. Bitte aktualisiere die Übersicht."
case .invalidResponse: "Die Serverantwort konnte nicht verarbeitet werden."
case .unexpected: "Ein unerwarteter Fehler ist aufgetreten. Bitte versuche es erneut."
}
}
var allowsRetry: Bool {
self != .unauthorized && self != .forbidden
}
}
enum LeadOverviewSelection: String, CaseIterable, Identifiable {
case newest, alphabetical, open
var id: Self { self }
var title: LocalizedStringKey {
switch self {
case .newest: "Neueste"
case .alphabetical: "A–Z"
case .open: "Offen"
}
}
}

View File

@ -0,0 +1,51 @@
import SwiftUI
struct LeadResultsSection: View {
let leads: [Lead]
let columns: [GridItem]
@Binding var selection: LeadOverviewSelection
let cardMotion: Namespace.ID
let isBusy: Bool
let onOpen: (Lead) -> Void
let onBookmark: (Lead) -> Void
var body: some View {
VStack(alignment: .leading, spacing: 14) {
ViewThatFits(in: .horizontal) {
HStack {
heading
Spacer(minLength: 20)
picker.frame(width: 300)
}
VStack(alignment: .leading, spacing: 12) {
heading
picker
}
}
LeadCardGrid(columnCount: columns.count) {
ForEach(leads) { lead in
LeadCardView(lead: lead, isBusy: isBusy,
onOpen: { onOpen(lead) }, onBookmark: { onBookmark(lead) })
.matchedGeometryEffect(id: lead.isBookmarked ? "regular-\(lead.id)" : "flight-\(lead.id)",
in: cardMotion)
}
}
}
}
private var heading: some View {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text("Alle Interessenten").font(.title2.bold())
Text(leads.count, format: .number).foregroundStyle(.secondary)
}
}
private var picker: some View {
Picker("Sortierung und Filter", selection: $selection) {
ForEach(LeadOverviewSelection.allCases) { option in
Text(option.title).tag(option)
}
}
.pickerStyle(.segmented)
}
}

View File

@ -0,0 +1,35 @@
import SwiftUI
struct LeadSearchField: View {
@Binding var text: String
var isFocused: FocusState<Bool>.Binding
let onClear: () -> Void
var body: some View {
HStack(spacing: 12) {
Image(systemName: "magnifyingglass")
.accessibilityHidden(true)
TextField("Firma, Person, Ort oder LeadID", text: $text)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.submitLabel(.search)
.focused(isFocused)
.onSubmit { isFocused.wrappedValue = false }
.accessibilityLabel("Interessenten suchen")
if !text.isEmpty {
Button(action: onClear) {
Image(systemName: "xmark.circle.fill")
.frame(width: 44, height: 44)
.contentShape(.rect)
}
.buttonStyle(.plain)
.accessibilityLabel("Suche löschen")
.padding(.vertical, -10)
}
}
.font(.title3)
.foregroundStyle(.secondary)
.padding(18)
.background(Color.primary.opacity(0.06), in: .rect(cornerRadius: 15))
}
}

View File

@ -0,0 +1,73 @@
import SwiftUI
struct LeadStatusView: View {
let offerCount: Int
let unsignedContractCount: Int
var highlighted = false
var body: some View {
ViewThatFits(in: .horizontal) {
HStack(spacing: 8) {
LeadOfferBadge(count: offerCount, highlighted: highlighted)
contractStatus
}
VStack(alignment: .leading, spacing: 8) {
LeadOfferBadge(count: offerCount, highlighted: highlighted)
contractStatus
}
}
.font(.footnote)
}
@ViewBuilder
private var contractStatus: some View {
if offerCount > 0 && unsignedContractCount == 0 {
HStack(spacing: 5) {
Image(systemName: "checkmark.circle.fill")
.symbolRenderingMode(.palette)
.foregroundStyle(.white, LeadOverviewStyle.successGreen)
.font(.title3)
Text("signiert")
.foregroundStyle(highlighted ? Color.white.opacity(0.9) : Color.secondary)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("Alle Verträge unterschrieben")
} else {
Text(contractLabel)
.foregroundStyle(highlighted ? Color.white.opacity(0.9) : Color.secondary)
}
}
private var contractLabel: LocalizedStringResource {
switch unsignedContractCount {
case 0: "Nichts zu unterschreiben"
case 1: "1 Vertrag offen"
default: "\(unsignedContractCount) Verträge offen"
}
}
}
private struct LeadOfferBadge: View {
let count: Int
let highlighted: Bool
var body: some View {
Text(label)
.fontWeight(.medium)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.foregroundStyle(highlighted ? .white : (count == 0 ? Color.secondary : LeadOverviewStyle.blue))
.background(
highlighted ? Color.white.opacity(0.25) : (count == 0 ? Color.primary.opacity(0.05) : LeadOverviewStyle.blue.opacity(0.08)),
in: .capsule
)
}
private var label: LocalizedStringResource {
switch count {
case 0: "Kein Angebot"
case 1: "1 Angebot"
default: "\(count) Angebote"
}
}
}

View File

@ -0,0 +1,52 @@
import SwiftUI
import QuickLook
/// Native full-screen PDF reading, including zoom, page navigation and system open-in actions.
struct LeadDocumentPreview: View {
let url: URL
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
LeadQuickLookView(url: url)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button(role: .confirm) { dismiss() }
.labelStyle(.iconOnly)
.accessibilityLabel("Zurück zur Unterschrift")
}
}
}
}
}
private struct LeadQuickLookView: UIViewControllerRepresentable {
let url: URL
func makeUIViewController(context: Context) -> QLPreviewController {
let controller = QLPreviewController()
controller.dataSource = context.coordinator
return controller
}
func updateUIViewController(_ controller: QLPreviewController, context: Context) {
guard context.coordinator.url != url else { return }
context.coordinator.url = url
controller.reloadData()
}
func makeCoordinator() -> Coordinator { Coordinator(url: url) }
final class Coordinator: NSObject, QLPreviewControllerDataSource {
var url: URL
init(url: URL) { self.url = url }
func numberOfPreviewItems(in controller: QLPreviewController) -> Int { 1 }
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> any QLPreviewItem {
url as NSURL
}
}
}

View File

@ -0,0 +1,72 @@
import SwiftUI
import PDFKit
struct LeadPDFView: UIViewRepresentable {
let url: URL
var signaturePlacement: PDFSignaturePlacement?
var fitsWholePage = false
var onPageChanged: ((Int, Int) -> Void)?
func makeUIView(context: Context) -> PDFView {
let view = PDFView()
view.displayMode = fitsWholePage ? .singlePage : .singlePageContinuous
if fitsWholePage {
view.displayDirection = .horizontal
view.usePageViewController(true)
}
view.autoScales = true
view.backgroundColor = .secondarySystemGroupedBackground
context.coordinator.observe(view)
return view
}
func updateUIView(_ view: PDFView, context: Context) {
context.coordinator.onPageChanged = onPageChanged
guard view.document?.documentURL != url else { return }
view.document = PDFDocument(url: url)
view.autoScales = true
if let signaturePlacement,
let page = view.document?.page(at: signaturePlacement.pageIndex) {
// The marker exists only in the preview; the signing service reads the original file.
let marker = PDFAnnotation(bounds: signaturePlacement.bounds, forType: .square, withProperties: nil)
marker.color = .systemOrange
marker.interiorColor = UIColor.systemOrange.withAlphaComponent(0.08)
marker.contents = "Unterschrift Kunde"
let border = PDFBorder()
border.lineWidth = 1
border.style = .dashed
border.dashPattern = [3, 2]
marker.border = border
page.addAnnotation(marker)
view.go(to: page)
}
context.coordinator.report(view)
}
func makeCoordinator() -> Coordinator { Coordinator() }
final class Coordinator: NSObject {
var onPageChanged: ((Int, Int) -> Void)?
func observe(_ view: PDFView) {
NotificationCenter.default.addObserver(
self, selector: #selector(pageChanged(_:)), name: .PDFViewPageChanged, object: view
)
}
@objc private func pageChanged(_ notification: Notification) {
guard let view = notification.object as? PDFView else { return }
report(view)
}
func report(_ view: PDFView) {
guard let document = view.document, let page = view.currentPage else { return }
let number = document.index(for: page) + 1
let count = document.pageCount
// Avoid changing SwiftUI state while the representable is updating.
Task { @MainActor [weak self] in self?.onPageChanged?(number, count) }
}
deinit { NotificationCenter.default.removeObserver(self) }
}
}

View File

@ -0,0 +1,44 @@
import SwiftUI
import PencilKit
struct LeadSignatureCanvas: UIViewRepresentable {
@Binding var drawing: PKDrawing
@Environment(\.colorScheme) private var colorScheme
func makeUIView(context: Context) -> PKCanvasView {
let view = PKCanvasView()
view.delegate = context.coordinator
view.drawingPolicy = .anyInput
// PencilKit renders black ink as white in Dark Mode, including existing strokes.
// Keep the stored ink black; the PDF service exports onto a white document.
view.tool = PKInkingTool(.pen, color: .black, width: 2)
view.overrideUserInterfaceStyle = colorScheme == .dark ? .dark : .light
view.backgroundColor = .clear
view.isOpaque = false
view.isScrollEnabled = false
view.drawing = drawing
view.accessibilityLabel = "Unterschrift"
view.accessibilityHint = "Mit Finger oder Apple Pencil unterschreiben"
return view
}
func updateUIView(_ view: PKCanvasView, context: Context) {
context.coordinator.parent = self
view.overrideUserInterfaceStyle = colorScheme == .dark ? .dark : .light
if view.drawing != drawing {
view.drawing = drawing
}
}
func makeCoordinator() -> Coordinator { Coordinator(parent: self) }
final class Coordinator: NSObject, PKCanvasViewDelegate {
var parent: LeadSignatureCanvas
init(parent: LeadSignatureCanvas) { self.parent = parent }
func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) {
parent.drawing = canvasView.drawing
}
}
}

View File

@ -0,0 +1,43 @@
import SwiftUI
struct LeadSigningCompletionView: View {
let model: LeadSigningViewModel
var body: some View {
VStack(spacing: 16) {
if model.isBusy {
ProgressView()
.controlSize(.large)
Text(model.isUploading ? "PDFs werden hochgeladen …" : "PDFs werden signiert …")
.font(.headline)
if model.isDemoUpload {
Text("Demo-Modus: Der Upload wird simuliert.")
.font(.subheadline).foregroundStyle(.secondary)
}
} else if let error = model.uploadError {
Label("Upload nicht abgeschlossen", systemImage: "exclamationmark.triangle")
.font(.headline)
Text(error).foregroundStyle(.secondary)
Button("Erneut versuchen") {
Task { await model.uploadSignedDocuments() }
}
.buttonStyle(.borderedProminent)
} else if let result = model.uploadResult {
Image(systemName: "checkmark.circle.fill")
.font(.largeTitle).foregroundStyle(.green)
switch result {
case .uploaded:
Text("PDFs signiert und hochgeladen").font(.headline)
case .simulated:
Text("PDFs signiert und lokal gespeichert").font(.headline)
Text("Demo-Upload abgeschlossen. Es wurden keine PDFs übertragen.")
.font(.subheadline).foregroundStyle(.secondary)
}
}
}
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity, minHeight: 220)
.padding(24)
.accessibilityElement(children: .contain)
}
}

View File

@ -0,0 +1,60 @@
import SwiftUI
struct LeadSigningDocumentListView: View {
let documents: [LeadDocument]
let currentID: String?
let signedIDs: Set<String>
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("DOKUMENTE").font(.caption).foregroundStyle(.secondary)
.padding(.horizontal, 6)
VStack(spacing: 0) {
ForEach(Array(documents.enumerated()), id: \.element.id) { index, document in
LeadSigningDocumentRow(
title: document.title.replacingOccurrences(of: ".pdf", with: ""),
number: index + 1,
isCurrent: document.id == currentID && document.status != .signed && !signedIDs.contains(document.id),
isSigned: document.status == .signed || signedIDs.contains(document.id)
)
if document.id != documents.last?.id {
Divider().padding(.leading, 44)
}
}
}
.background(Color(uiColor: .secondarySystemGroupedBackground), in: .rect(cornerRadius: 12))
.clipShape(.rect(cornerRadius: 12))
}
.padding(12)
}
}
struct LeadSigningDocumentRow: View {
let title: String
let number: Int
let isCurrent: Bool
let isSigned: Bool
var body: some View {
HStack(spacing: 12) {
if isSigned {
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green).frame(width: 20)
} else {
Text(number.formatted()).foregroundStyle(isCurrent ? Color.accentColor : .secondary)
.frame(width: 20)
}
VStack(alignment: .leading, spacing: 3) {
Text(title).fontWeight(isCurrent ? .semibold : .regular).lineLimit(2)
Text(isSigned ? "Unterschrieben" : isCurrent ? "Jetzt unterschreiben" : "Ausstehend")
.font(.caption)
.foregroundStyle(isSigned ? Color.green : isCurrent ? Color.accentColor : .secondary)
}
Spacer(minLength: 0)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(isCurrent ? Color.accentColor.opacity(0.10) : .clear)
.accessibilityElement(children: .combine)
.accessibilityAddTraits(isCurrent ? .isSelected : [])
}
}

View File

@ -0,0 +1,28 @@
import SwiftUI
struct LeadSigningFooterView: View {
let title: String
let canSign: Bool
let isSigning: Bool
let isLast: Bool
let onSign: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Mit der Unterschrift bestätigt der Kunde den Inhalt von „\(title)“. Die unterschriebene PDF wird lokal gespeichert.")
.font(.caption).foregroundStyle(.secondary)
.padding(.horizontal, 16)
Button(action: onSign) {
HStack {
if isSigning { ProgressView().tint(.white) }
Text(isLast ? "Unterschreiben und abschließen" : "Unterschreiben und weiter")
.font(.headline)
}
.frame(maxWidth: .infinity).padding(.vertical, 12)
}
.buttonStyle(.borderedProminent)
.buttonBorderShape(.roundedRectangle(radius: 12))
.disabled(!canSign)
}
}
}

View File

@ -0,0 +1,34 @@
import SwiftUI
struct LeadSigningHeaderView: ToolbarContent {
let offerID: String
let current: Int
let total: Int
let isComplete: Bool
let isSigning: Bool
let canConfirm: Bool
let onClose: () -> Void
var body: some ToolbarContent {
ToolbarItem(placement: .cancellationAction) {
Button(role: .cancel, action: onClose)
.labelStyle(.iconOnly)
.disabled(isSigning)
}
ToolbarItem(placement: .principal) {
VStack(spacing: 3) {
Text(offerID).font(.headline)
if isComplete {
Text("Alle Dokumente unterschrieben").font(.caption).foregroundStyle(.secondary)
} else {
Text("Dokument \(current) von \(total)").font(.caption).foregroundStyle(.secondary)
}
}
}
ToolbarItem(placement: .confirmationAction) {
Button(role: .confirm, action: onClose)
.labelStyle(.iconOnly)
.disabled(!canConfirm)
}
}
}

View File

@ -0,0 +1,42 @@
import SwiftUI
import PDFKit
import QuickLook
struct LeadSigningPreviewView: View {
let offerID: String
let url: URL
let placement: PDFSignaturePlacement?
@State private var showsPreview = false
@State private var pageNumber = 1
@State private var pageCount = 1
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("VORSCHAU · \(offerID)")
Spacer()
Button("In Vorschau öffnen", systemImage: "arrow.up.left.and.arrow.down.right") {
showsPreview = true
}
.font(.subheadline)
.foregroundStyle(.tint)
}
.font(.caption).foregroundStyle(.secondary).padding(.horizontal, 16)
LeadPDFView(url: url, signaturePlacement: placement, fitsWholePage: true) { current, total in
pageNumber = current
pageCount = total
}
.frame(height: 310)
.clipShape(.rect(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).stroke(.gray.opacity(0.25)))
Text("Seite \(pageNumber) von \(pageCount)")
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .trailing)
.padding(.horizontal, 16)
}
.fullScreenCover(isPresented: $showsPreview) {
LeadDocumentPreview(url: url)
}
}
}

View File

@ -0,0 +1,156 @@
import SwiftUI
import PencilKit
struct LeadSigningSheet: View {
@State private var model: LeadSigningViewModel
@State private var confirmDiscard = false
@Environment(\.dismiss) private var dismiss
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
let onSigned: (LeadDocument, URL) -> Void
init(request: LeadSigningRequest, leadID: String, salesCode: String,
repository: any LeadDetailRepository, onSigned: @escaping (LeadDocument, URL) -> Void) {
_model = State(initialValue: LeadSigningViewModel(
request: request, leadID: leadID, salesCode: salesCode, repository: repository
))
self.onSigned = onSigned
}
init(model: LeadSigningViewModel, onSigned: @escaping (LeadDocument, URL) -> Void) {
_model = State(initialValue: model)
self.onSigned = onSigned
}
var body: some View {
NavigationStack {
GeometryReader { geometry in
if geometry.size.width >= 700 && !dynamicTypeSize.isAccessibilitySize {
HStack(alignment: .top, spacing: 0) {
ScrollView {
LeadSigningDocumentListView(
documents: model.request.documents, currentID: model.currentDocument?.id,
signedIDs: Set(model.signedURLs.keys)
)
}
.frame(width: 240)
Divider()
ScrollView {
LeadSigningContentView(model: model, onSigned: onSigned)
.padding(16)
}
}
} else {
ScrollView {
VStack(spacing: 0) {
LeadSigningDocumentListView(
documents: model.request.documents, currentID: model.currentDocument?.id,
signedIDs: Set(model.signedURLs.keys)
)
LeadSigningContentView(model: model, onSigned: onSigned)
.padding(16)
}
}
}
}
.background(Color(uiColor: .systemGroupedBackground))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
LeadSigningHeaderView(
offerID: model.request.offerID, current: model.currentDocumentNumber,
total: model.request.documents.count, isComplete: model.isComplete,
isSigning: model.isBusy, canConfirm: model.canFinish
) {
if model.drawing.strokes.isEmpty { dismiss() } else { confirmDiscard = true }
}
}
}
.tint(LeadDetailStyle.blue)
.frame(idealWidth: 1000, idealHeight: 790)
.presentationSizing(.fitted)
.presentationCornerRadius(20)
.interactiveDismissDisabled(model.isBusy || !model.drawing.strokes.isEmpty)
.confirmationDialog("Unterschrift verwerfen?", isPresented: $confirmDiscard, titleVisibility: .visible) {
Button("Unterschrift verwerfen", role: .destructive) { dismiss() }
Button("Weiter unterschreiben", role: .cancel) {}
} message: {
Text("Bereits gespeicherte PDFs bleiben erhalten. Nur die aktuelle Eingabe wird verworfen.")
}
.task {
if model.sourceURL == nil { await model.loadCurrentDocument() }
}
}
}
#Preview("Unterschreiben", traits: .fixedLayout(width: 1000, height: 790), .modifier(LeadSigningPreviewData())) {
LeadSigningPreviewHost()
}
private struct LeadSigningPreviewHost: View {
@Environment(LeadSigningViewModel.self) private var model
var body: some View {
LeadSigningSheet(model: model, onSigned: { _, _ in })
}
}
private struct LeadSigningPreviewData: PreviewModifier {
static func makeSharedContext() async throws -> LeadSigningViewModel {
let repository = DefaultLeadDetailRepository(
remoteService: StubLeadDetailRemoteService(delay: .zero)
)
let detail = try await repository.detail(id: "L-240318", salesCode: "MKR")
guard let offer = detail.offers.first else { throw PDFSigningError.invalidDocument }
let request = LeadSigningRequest(offerID: offer.id, signerName: "Klaus Hoffmann",
documents: offer.documents)
let model = LeadSigningViewModel(request: request, leadID: detail.lead.id,
salesCode: "MKR", repository: repository)
await model.loadCurrentDocument()
return model
}
func body(content: Content, context: LeadSigningViewModel) -> some View {
content.environment(context)
}
}
private struct LeadSigningContentView: View {
@Bindable var model: LeadSigningViewModel
let onSigned: (LeadDocument, URL) -> Void
var body: some View {
VStack(spacing: 20) {
if let url = model.sourceURL {
LeadSigningPreviewView(
offerID: model.request.offerID, url: url,
placement: model.isComplete ? nil : model.currentDocument?.signaturePlacement
)
} else if model.isLoading {
ProgressView("PDF wird geladen …")
.frame(maxWidth: .infinity, minHeight: 310)
}
if model.showsCompletion {
LeadSigningCompletionView(model: model)
} else {
LeadSigningSignatureView(
signerName: model.request.signerName, drawing: $model.drawing,
isEnabled: model.sourceURL != nil && !model.isSigning
)
.padding(.top, 16)
if let message = model.message {
Text(message).font(.callout).foregroundStyle(.red)
if model.sourceURL == nil {
Button("Erneut versuchen") { Task { await model.loadCurrentDocument() } }
.disabled(model.isLoading)
}
}
LeadSigningFooterView(
title: model.currentDocument?.title.replacingOccurrences(of: ".pdf", with: "") ?? "",
canSign: model.canSign, isSigning: model.isSigning,
isLast: model.isLastPendingDocument
) {
Task { await model.signAndAdvance(onSigned: onSigned) }
}
}
}
}
}

View File

@ -0,0 +1,65 @@
import SwiftUI
import PencilKit
#Preview("Unterschrift · Dark Mode", traits: .fixedLayout(width: 700, height: 240)) {
@Previewable @State var drawing = PKDrawing(strokes: [
PKStroke(
ink: PKInk(.pen, color: .black),
path: PKStrokePath(controlPoints: [
CGPoint(x: 100, y: 90), CGPoint(x: 150, y: 35),
CGPoint(x: 140, y: 95), CGPoint(x: 210, y: 55),
CGPoint(x: 240, y: 90), CGPoint(x: 320, y: 65)
].enumerated().map { index, point in
PKStrokePoint(location: point, timeOffset: Double(index) * 0.1,
size: CGSize(width: 3, height: 3), opacity: 1, force: 1,
azimuth: 0, altitude: .pi / 2)
}, creationDate: .now)
)
])
LeadSigningSignatureView(signerName: "Klaus Hoffmann", drawing: $drawing, isEnabled: true)
.padding(20)
.background(Color(uiColor: .systemGroupedBackground))
.preferredColorScheme(.dark)
}
struct LeadSigningSignatureView: View {
let signerName: String
@Binding var drawing: PKDrawing
let isEnabled: Bool
var body: some View {
VStack(spacing: 8) {
HStack {
Text("UNTERSCHRIFT · \(signerName)")
.font(.caption).foregroundStyle(.secondary)
Spacer()
Button("Löschen") { drawing = PKDrawing() }
.disabled(drawing.strokes.isEmpty || !isEnabled)
}
.padding(.horizontal, 16)
ZStack {
VStack {
Spacer()
if drawing.strokes.isEmpty {
Label("Mit Finger oder Apple Pencil unterschreiben", systemImage: "signature")
.font(.subheadline).foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
Spacer()
HStack {
Text("×").foregroundStyle(.secondary)
Spacer()
}
Rectangle().fill(Color(uiColor: .separator)).frame(height: 1)
}
.padding(24)
.allowsHitTesting(false)
LeadSignatureCanvas(drawing: $drawing)
.allowsHitTesting(isEnabled)
}
.frame(height: 150)
.background(Color(uiColor: .secondarySystemGroupedBackground), in: .rect(cornerRadius: 12))
.clipShape(.rect(cornerRadius: 12))
}
}
}

View File

@ -0,0 +1,145 @@
import Foundation
import Observation
import PDFKit
import PencilKit
struct LeadSigningRequest: Identifiable {
let id = UUID()
let offerID: String
let signerName: String
let documents: [LeadDocument]
}
@MainActor
@Observable
final class LeadSigningViewModel {
let request: LeadSigningRequest
let pendingDocuments: [LeadDocument]
private(set) var currentIndex = 0
private(set) var sourceURL: URL?
private(set) var signedURLs: [String: URL] = [:]
private(set) var isLoading = false
private(set) var isSigning = false
private(set) var isComplete = false
private(set) var isUploading = false
private(set) var uploadResult: LeadPDFUploadResult?
private(set) var uploadError: String?
private(set) var message: String?
var drawing = PKDrawing()
private let repository: any LeadDetailRepository
private let signingService: any PDFSigningService
private let store: LeadSignedDocumentStore
private let uploadService: any LeadSignedPDFUploadService
private let leadID: String
private let salesCode: String
init(request: LeadSigningRequest, leadID: String, salesCode: String,
repository: any LeadDetailRepository,
signingService: (any PDFSigningService)? = nil, store: LeadSignedDocumentStore? = nil,
uploadService: (any LeadSignedPDFUploadService)? = nil) {
self.request = request
self.pendingDocuments = request.documents.filter { $0.status == .pending }
self.leadID = leadID
self.salesCode = salesCode
self.repository = repository
self.signingService = signingService ?? DefaultPDFSigningService()
self.store = store ?? LeadSignedDocumentStore()
self.uploadService = uploadService ?? StubLeadSignedPDFUploadService()
}
var currentDocumentNumber: Int {
guard let currentDocument,
let index = request.documents.firstIndex(where: { $0.id == currentDocument.id }) else { return 0 }
return index + 1
}
var isLastPendingDocument: Bool { currentIndex == pendingDocuments.count - 1 }
var isDemoUpload: Bool { uploadService.isSimulation }
var isBusy: Bool { isSigning || isUploading }
var canFinish: Bool { isComplete && uploadResult != nil && !isBusy }
var showsCompletion: Bool {
isComplete || (isSigning && isLastPendingDocument)
}
func uploadSignedDocuments() async {
guard isComplete, !isUploading, uploadResult == nil else { return }
isUploading = true
uploadError = nil
defer { isUploading = false }
do {
let files = pendingDocuments.compactMap { signedURLs[$0.id] }
guard files.count == pendingDocuments.count else { throw PDFSigningError.invalidDocument }
uploadResult = try await uploadService.upload(
files, leadID: leadID, offerID: request.offerID, salesCode: salesCode
)
} catch is CancellationError {
uploadError = "Die Übertragung wurde unterbrochen. Die signierten PDFs bleiben gespeichert."
} catch {
uploadError = error.localizedDescription
}
}
var currentDocument: LeadDocument? {
pendingDocuments.indices.contains(currentIndex) ? pendingDocuments[currentIndex] : nil
}
var canSign: Bool {
sourceURL != nil && currentDocument?.signaturePlacement != nil
&& !drawing.strokes.isEmpty && !isLoading && !isSigning && !isComplete
}
func loadCurrentDocument() async {
guard let document = currentDocument else { return }
isLoading = true
sourceURL = nil
message = nil
defer { isLoading = false }
do {
let url = try await DownloadLeadDocumentUseCase(repository: repository)
.execute(document, leadID: leadID, salesCode: salesCode)
try Task.checkCancellation()
guard let pdf = PDFDocument(url: url), !pdf.isLocked else {
throw PDFSigningError.invalidDocument
}
guard let placement = document.signaturePlacement,
placement.pageIndex >= 0,
let page = pdf.page(at: placement.pageIndex),
page.bounds(for: .mediaBox).contains(placement.bounds) else {
throw PDFSigningError.invalidPlacement
}
sourceURL = url
} catch is CancellationError {
} catch {
message = error.localizedDescription
}
}
func signAndAdvance(onSigned: (LeadDocument, URL) -> Void) async {
guard canSign, let document = currentDocument,
let sourceURL, let placement = document.signaturePlacement else { return }
isSigning = true
message = nil
defer { isSigning = false }
do {
let result = try await signingService.sign(source: sourceURL, drawing: drawing, placement: placement)
try store.save(url: result, document: document,
leadID: leadID, salesCode: salesCode)
signedURLs[document.id] = result
onSigned(document, result)
drawing = PKDrawing()
if currentIndex + 1 < pendingDocuments.count {
currentIndex += 1
await loadCurrentDocument()
} else {
self.sourceURL = result
isComplete = true
await uploadSignedDocuments()
}
} catch is CancellationError {
} catch {
// Keep both the source and handwriting intact so saving can be retried.
message = error.localizedDescription
}
}
}

View File

@ -0,0 +1,289 @@
import SwiftUI
private enum LoginStyle {
static let blue = Color(red: 0, green: 0.44, blue: 0.78)
static let background = Color(uiColor: .systemGroupedBackground)
}
struct LoginView: View {
let onLogin: (LoginSession) -> Void
@State private var model: LoginViewModel
@State private var isLoginRequested = false
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
init(loginUseCase: any LoginUseCase, onLogin: @escaping (LoginSession) -> Void) {
_model = State(initialValue: LoginViewModel(loginUseCase: loginUseCase))
self.onLogin = onLogin
}
var body: some View {
GeometryReader { geometry in
let isWide = geometry.size.width >= 850
&& geometry.size.width > geometry.size.height
&& !dynamicTypeSize.isAccessibilitySize
ScrollView {
if isWide {
HStack(spacing: 0) {
LoginBrandPanel(isWide: true)
.frame(width: geometry.size.width * 0.54)
LoginFormPanel(model: model, isLoginRequested: $isLoginRequested, isWide: true)
.frame(maxWidth: .infinity)
}
.frame(minHeight: geometry.size.height, alignment: .top)
} else {
VStack(spacing: 0) {
LoginBrandPanel(isWide: false)
.fixedSize(horizontal: false, vertical: true)
LoginFormPanel(model: model, isLoginRequested: $isLoginRequested, isWide: false)
.fixedSize(horizontal: false, vertical: true)
}
.frame(minHeight: geometry.size.height, alignment: .top)
}
}
.scrollDismissesKeyboard(.interactively)
.background {
if isWide {
HStack(spacing: 0) {
LoginStyle.blue.frame(width: geometry.size.width * 0.54)
LoginStyle.background
}
.ignoresSafeArea()
} else {
LoginStyle.background.ignoresSafeArea()
}
}
}
.tint(LoginStyle.blue)
.task(id: isLoginRequested) {
guard isLoginRequested else { return }
defer { isLoginRequested = false }
if let session = await model.signIn(), !Task.isCancelled {
onLogin(session)
}
}
}
}
private struct LoginBrandPanel: View {
let isWide: Bool
@ScaledMetric(relativeTo: .largeTitle) private var titleSize = 40
var body: some View {
VStack(alignment: .leading, spacing: 0) {
if isWide {
Spacer(minLength: 60)
}
VStack(alignment: .leading, spacing: 24) {
Image("LoginLogo")
.resizable()
.scaledToFit()
.frame(width: isWide ? 96 : 72, height: isWide ? 96 : 72)
.clipShape(.rect(cornerRadius: 23))
.overlay {
RoundedRectangle(cornerRadius: 23)
.strokeBorder(.white.opacity(0.8), lineWidth: 2)
}
.shadow(color: .black.opacity(0.12), radius: 12, y: 6)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 8) {
Text("DrachenGas")
.font(.system(size: titleSize, weight: .bold))
.lineLimit(1)
.minimumScaleFactor(0.5)
Text("Außendienst")
.font(.title3)
.foregroundStyle(.white.opacity(0.85))
}
}
.frame(maxWidth: .infinity, alignment: .leading)
if isWide {
Spacer(minLength: 60)
}
Text("Drachen-Propangas GmbH · Wetzlar")
.font(.footnote)
.foregroundStyle(.white.opacity(0.75))
.padding(.top, 32)
}
.padding(isWide ? 42 : 28)
.frame(maxWidth: .infinity, maxHeight: isWide ? .infinity : nil, alignment: .leading)
.background {
LoginStyle.blue.ignoresSafeArea(.container, edges: .top)
}
.foregroundStyle(.white)
}
}
private struct LoginFormPanel: View {
@Bindable var model: LoginViewModel
@Binding var isLoginRequested: Bool
let isWide: Bool
@FocusState private var isCodeFocused: Bool
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Spacer(minLength: isWide ? 60 : 0)
VStack(alignment: .leading, spacing: 24) {
VStack(alignment: .leading, spacing: 8) {
Text("Anmelden")
.font(.largeTitle.bold())
Text("Mit deinem persönlichen Vertriebskürzel.")
.foregroundStyle(.secondary)
}
VStack(alignment: .leading, spacing: 10) {
Text("KÜRZEL")
.font(.caption.weight(.medium))
.tracking(1)
.foregroundStyle(.secondary)
HStack(spacing: 14) {
Image(systemName: "person")
.foregroundStyle(LoginStyle.blue)
.accessibilityHidden(true)
TextField("z. B. MKR", text: $model.salesCode)
.font(.title3.bold())
.tracking(2)
.textContentType(.username)
.textInputAutocapitalization(.characters)
.autocorrectionDisabled()
.submitLabel(.go)
.focused($isCodeFocused)
.accessibilityLabel("Vertriebskürzel")
.onSubmit(requestLogin)
}
.padding(18)
.background(Color(uiColor: .secondarySystemGroupedBackground), in: .rect(cornerRadius: 14))
.overlay {
RoundedRectangle(cornerRadius: 14)
.strokeBorder(
isCodeFocused ? LoginStyle.blue : Color.primary.opacity(0.08),
lineWidth: 1
)
}
.disabled(model.isLoading || isLoginRequested)
Text("Mindestens 3 Zeichen, z. B. MKR.")
.font(.footnote)
.foregroundStyle(.secondary)
}
if let errorMessage = model.errorMessage {
Label {
Text(errorMessage)
} icon: {
Image(systemName: "exclamationmark.circle")
}
.font(.callout)
.foregroundStyle(.red)
.accessibilityIdentifier("loginError")
}
Button(action: requestLogin) {
HStack(spacing: 10) {
if model.isLoading {
ProgressView().tint(.white)
}
Text(model.isLoading ? "Anmelden …" : "Anmelden")
.font(.headline)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.foregroundStyle(.white)
.background(
LoginStyle.blue.opacity(model.canSubmit ? 1 : 0.42),
in: .rect(cornerRadius: 14)
)
}
.buttonStyle(.plain)
.disabled(!model.canSubmit || isLoginRequested)
}
Spacer(minLength: 48)
LoginFooter()
}
.padding(isWide ? 48 : 28)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(LoginStyle.background)
}
private func requestLogin() {
guard model.canSubmit, !isLoginRequested else { return }
isCodeFocused = false
isLoginRequested = true
}
}
private struct LoginFooter: View {
@State private var isShowingContact = false
private let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "–"
var body: some View {
ViewThatFits(in: .horizontal) {
HStack(alignment: .firstTextBaseline, spacing: 16) {
LoginContactButton(isShowingContact: $isShowingContact)
Spacer(minLength: 0)
Text("Version \(version)")
}
VStack(alignment: .leading, spacing: 16) {
LoginContactButton(isShowingContact: $isShowingContact)
Text("Version \(version)")
}
}
.font(.footnote)
.foregroundStyle(.secondary)
.alert("Vertriebsleitung kontaktieren", isPresented: $isShowingContact) {
Button("OK", role: .cancel) {}
} message: {
Text("Bitte wende dich über die bekannten Kontaktwege an deine Vertriebsleitung. Eine direkte Kontaktmöglichkeit wird hier später ergänzt.")
}
}
}
private struct LoginContactButton: View {
@Binding var isShowingContact: Bool
var body: some View {
ViewThatFits(in: .horizontal) {
HStack(spacing: 4) {
Text("Kürzel vergessen?")
contactButton
}
VStack(alignment: .leading, spacing: 4) {
Text("Kürzel vergessen?")
contactButton
}
}
}
private var contactButton: some View {
Button("Vertriebsleitung kontaktieren") {
isShowingContact = true
}
.buttonStyle(.plain)
.foregroundStyle(LoginStyle.blue)
}
}
#Preview("Breit", traits: .fixedLayout(width: 1194, height: 834)) {
LoginView(loginUseCase: DefaultLoginUseCase(authenticationService: StubAuthenticationService())) { _ in }
}
#Preview("iPad Hochkant", traits: .fixedLayout(width: 834, height: 1194)) {
LoginView(loginUseCase: DefaultLoginUseCase(authenticationService: StubAuthenticationService())) { _ in }
}
#Preview("Schmal", traits: .fixedLayout(width: 390, height: 844)) {
LoginView(loginUseCase: DefaultLoginUseCase(authenticationService: StubAuthenticationService())) { _ in }
}
#Preview("Große Schrift", traits: .fixedLayout(width: 390, height: 844)) {
LoginView(loginUseCase: DefaultLoginUseCase(authenticationService: StubAuthenticationService())) { _ in }
.environment(\.dynamicTypeSize, .accessibility3)
}

View File

@ -0,0 +1,47 @@
import Foundation
import Observation
@MainActor
@Observable
final class LoginViewModel {
var salesCode = "" {
didSet { errorMessage = nil }
}
private(set) var isLoading = false
private(set) var errorMessage: LocalizedStringResource?
@ObservationIgnored private let loginUseCase: any LoginUseCase
init(loginUseCase: any LoginUseCase) {
self.loginUseCase = loginUseCase
}
var canSubmit: Bool {
SalesCodeValidator.isValid(salesCode) && !isLoading
}
func signIn() async -> LoginSession? {
guard !isLoading else { return nil }
errorMessage = nil
isLoading = true
defer { isLoading = false }
do {
let session = try await loginUseCase.execute(salesCode: salesCode)
try Task.checkCancellation()
return session
} catch is CancellationError {
return nil
} catch {
switch error as? LoginError {
case .invalidSalesCode:
errorMessage = "Bitte gib ein Kürzel mit mindestens 3 Zeichen ein."
case .rejected:
errorMessage = "Das Kürzel wurde nicht erkannt. Bitte prüfe deine Eingabe."
default:
errorMessage = "Die Anmeldung ist derzeit nicht möglich. Bitte versuche es erneut."
}
return nil
}
}
}