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,22 @@
import Foundation
/// Domain boundary; the concrete server integration will replace the stub.
protocol AuthenticationService: Sendable {
func signIn(salesCode: String) async throws -> LoginSession
}
struct LoginSession: Equatable, Sendable {
let salesCode: String
let displayName: String?
init(salesCode: String, displayName: String? = nil) {
self.salesCode = salesCode
self.displayName = displayName
}
}
enum LoginError: Error, Equatable {
case invalidSalesCode
case rejected
case unavailable
}

View File

@ -0,0 +1,28 @@
import MapKit
@MainActor
protocol CustomerLocationService {
func coordinate(for address: String) async throws -> CLLocationCoordinate2D
}
enum CustomerLocationError: Error {
case notFound
}
@MainActor
struct AppleMapsCustomerLocationService: CustomerLocationService {
func coordinate(for address: String) async throws -> CLLocationCoordinate2D {
guard let request = MKGeocodingRequest(addressString: address) else {
throw CustomerLocationError.notFound
}
let items: [MKMapItem]
do {
items = try await request.mapItems
} catch let error as MKError where error.code == .placemarkNotFound {
throw CustomerLocationError.notFound
}
try Task.checkCancellation()
guard let item = items.first else { throw CustomerLocationError.notFound }
return item.location.coordinate
}
}

View File

@ -0,0 +1,30 @@
import Foundation
protocol LoginUseCase: Sendable {
func execute(salesCode: String) async throws -> LoginSession
}
/// Shared by the use case and the form so that both enforce the same rule.
enum SalesCodeValidator {
static func normalized(_ input: String) -> String {
input.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
}
static func isValid(_ input: String) -> Bool {
normalized(input).count >= 3
}
}
struct DefaultLoginUseCase: LoginUseCase {
let authenticationService: any AuthenticationService
func execute(salesCode: String) async throws -> LoginSession {
guard SalesCodeValidator.isValid(salesCode) else {
throw LoginError.invalidSalesCode
}
try Task.checkCancellation()
return try await authenticationService.signIn(
salesCode: SalesCodeValidator.normalized(salesCode)
)
}
}

View File

@ -0,0 +1,76 @@
import Foundation
import PDFKit
import PencilKit
import UIKit
@MainActor
struct DefaultPDFSigningService: PDFSigningService {
var outputDirectory: URL = URL.applicationSupportDirectory
.appending(path: "SignedLeadDocuments", directoryHint: .isDirectory)
func sign(source: URL, drawing: PKDrawing, placement: PDFSignaturePlacement) async throws -> URL {
try Task.checkCancellation()
guard !drawing.strokes.isEmpty else { throw PDFSigningError.emptySignature }
guard let document = PDFDocument(url: source), !document.isLocked else {
throw PDFSigningError.invalidDocument
}
guard placement.pageIndex >= 0,
let page = document.page(at: placement.pageIndex),
placement.bounds.width > 0, placement.bounds.height > 0,
page.bounds(for: .mediaBox).contains(placement.bounds) else {
throw PDFSigningError.invalidPlacement
}
// Fit the handwriting into the configured field without changing its aspect ratio.
// PencilKit uses a top-left origin; PDF page coordinates use a bottom-left origin.
let sourceBounds = drawing.bounds.insetBy(dx: -2, dy: -2)
let target = placement.bounds.insetBy(dx: 4, dy: 4)
guard target.width > 0, target.height > 0,
sourceBounds.width.isFinite, sourceBounds.height.isFinite else {
throw PDFSigningError.invalidPlacement
}
let scale = min(target.width / sourceBounds.width, target.height / sourceBounds.height)
let origin = CGPoint(x: target.midX - sourceBounds.width * scale / 2,
y: target.midY - sourceBounds.height * scale / 2)
let annotation = PDFAnnotation(bounds: placement.bounds, forType: .ink, withProperties: nil)
annotation.color = .black
let border = PDFBorder()
border.lineWidth = max(0.7, 2 * scale)
annotation.border = border
annotation.shouldPrint = true
for stroke in drawing.strokes {
let path = UIBezierPath()
var isFirst = true
for point in stroke.path.interpolatedPoints(by: .distance(1)) {
let location = point.location.applying(stroke.transform)
let converted = CGPoint(
x: origin.x + (location.x - sourceBounds.minX) * scale,
y: origin.y + (sourceBounds.maxY - location.y) * scale
)
if isFirst {
path.move(to: converted)
isFirst = false
} else {
path.addLine(to: converted)
}
}
// A tap is also a valid ink mark.
if path.isEmpty == false {
path.addLine(to: CGPoint(x: path.currentPoint.x + 0.01, y: path.currentPoint.y))
annotation.add(path)
}
}
page.addAnnotation(annotation)
guard let data = document.dataRepresentation() else { throw PDFSigningError.saveFailed }
try Task.checkCancellation()
do {
try FileManager.default.createDirectory(at: outputDirectory, withIntermediateDirectories: true)
let destination = outputDirectory.appending(path: UUID().uuidString + ".pdf")
try data.write(to: destination, options: .atomic)
return destination
} catch {
throw PDFSigningError.saveFailed
}
}
}

View File

@ -0,0 +1,47 @@
import Foundation
/// Keeps locally signed copies discoverable after reopening a lead or restarting the app.
/// The source version is part of the key so a newer contract never inherits an older signature.
struct LeadSignedDocumentStore {
var directory: URL = URL.applicationSupportDirectory
.appending(path: "SignedLeadDocuments", directoryHint: .isDirectory)
private struct Record: Codable {
let leadID: String
let salesCode: String
let documentID: String
let fileID: String
let version: String
let filename: String
}
func save(url: URL, document: LeadDocument, leadID: String, salesCode: String) throws {
let record = Record(leadID: leadID, salesCode: salesCode, documentID: document.id,
fileID: document.file.id, version: document.file.version,
filename: url.lastPathComponent)
let data = try JSONEncoder().encode(record)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try data.write(to: directory.appending(path: url.deletingPathExtension().lastPathComponent + ".json"),
options: .atomic)
}
func savedURLs(documents: [LeadDocument], leadID: String, salesCode: String) -> [String: URL] {
guard let files = try? FileManager.default.contentsOfDirectory(
at: directory, includingPropertiesForKeys: nil
) else { return [:] }
var result: [String: URL] = [:]
for file in files where file.pathExtension == "json" {
guard let data = try? Data(contentsOf: file),
let record = try? JSONDecoder().decode(Record.self, from: data),
record.leadID == leadID, record.salesCode == salesCode,
documents.contains(where: {
$0.id == record.documentID && $0.file.id == record.fileID && $0.file.version == record.version
}) else { continue }
let url = directory.appending(path: record.filename)
if FileManager.default.fileExists(atPath: url.path) {
result[record.documentID] = url
}
}
return result
}
}

View File

@ -0,0 +1,27 @@
import Foundation
enum LeadPDFUploadResult: Sendable {
case uploaded
case simulated
}
@MainActor
protocol LeadSignedPDFUploadService {
var isSimulation: Bool { get }
func upload(_ files: [URL], leadID: String, offerID: String, salesCode: String) async throws -> LeadPDFUploadResult
}
/// UI demonstration until the PDF upload endpoint is available. Sends no data.
struct StubLeadSignedPDFUploadService: LeadSignedPDFUploadService {
let isSimulation = true
var delay: Duration = .seconds(2)
func upload(_ files: [URL], leadID: String, offerID: String, salesCode: String) async throws -> LeadPDFUploadResult {
guard !files.isEmpty, files.allSatisfy({ FileManager.default.fileExists(atPath: $0.path) }) else {
throw PDFSigningError.invalidDocument
}
try await Task.sleep(for: delay)
try Task.checkCancellation()
return .simulated
}
}

View File

@ -0,0 +1,26 @@
import Foundation
import PencilKit
/// Coordinates are PDF points, measured from the bottom-left of the media box.
struct PDFSignaturePlacement: Equatable, Sendable {
let pageIndex: Int
let bounds: CGRect
}
@MainActor
protocol PDFSigningService {
func sign(source: URL, drawing: PKDrawing, placement: PDFSignaturePlacement) async throws -> URL
}
enum PDFSigningError: LocalizedError {
case invalidDocument, emptySignature, invalidPlacement, saveFailed
var errorDescription: String? {
switch self {
case .invalidDocument: "Die PDF konnte nicht gelesen werden."
case .emptySignature: "Bitte zuerst unterschreiben."
case .invalidPlacement: "Für dieses Dokument fehlt eine gültige Unterschriftsposition."
case .saveFailed: "Die unterschriebene PDF konnte nicht gespeichert werden."
}
}
}

View File

@ -0,0 +1,21 @@
import Foundation
struct StubAuthenticationService: AuthenticationService {
let simulatedError: LoginError?
let delay: Duration
init(simulatedError: LoginError? = nil, delay: Duration = .milliseconds(600)) {
self.simulatedError = simulatedError
self.delay = delay
}
func signIn(salesCode: String) async throws -> LoginSession {
try await Task.sleep(for: delay)
try Task.checkCancellation()
if let simulatedError {
throw simulatedError
}
// Demo employee until the authentication API supplies the profile.
return LoginSession(salesCode: salesCode, displayName: "M. Krämer")
}
}

View File

@ -0,0 +1,71 @@
import SceneKit
import UIKit
/// Renders the actual catalog model once per variant, shared by all project rows.
@MainActor
final class TankThumbnailRenderer {
static let shared = TankThumbnailRenderer()
private let cache = NSCache<NSString, UIImage>()
func image(for definition: TankDefinition) -> UIImage? {
let key = definition.modelPath as NSString
if let image = cache.object(forKey: key) { return image }
guard let url = Bundle.main.url(forResource: definition.modelResourceName,
withExtension: definition.modelFileExtension),
let source = try? SCNScene(url: url, options: [.convertToYUp: true]) else { return nil }
let scene = SCNScene()
let model = SCNNode()
for child in source.rootNode.childNodes { model.addChildNode(child.clone()) }
// Bundle scenes may carry studio lights; use one consistent thumbnail setup.
model.enumerateChildNodes { node, _ in
node.light = nil
node.camera = nil
}
// Underground catalog models need an additional quarter-turn after Y-up conversion.
if definition.placement == .belowGround {
model.eulerAngles.x = -.pi / 2
}
let container = SCNNode()
container.addChildNode(model)
scene.rootNode.addChildNode(container)
// Measure in the parent's coordinate system, including the corrected orientation.
let bounds = container.boundingBox
let center = SCNVector3((bounds.min.x + bounds.max.x) / 2,
(bounds.min.y + bounds.max.y) / 2,
(bounds.min.z + bounds.max.z) / 2)
let extent = SCNVector3(bounds.max.x - bounds.min.x,
bounds.max.y - bounds.min.y,
bounds.max.z - bounds.min.z)
let radius = max(sqrt(extent.x * extent.x + extent.y * extent.y + extent.z * extent.z) / 2, 0.01)
let camera = SCNNode()
camera.camera = SCNCamera()
camera.camera?.usesOrthographicProjection = true
camera.camera?.orthographicScale = Double(radius * 0.64)
camera.camera?.zNear = Double(radius * 0.01)
camera.camera?.zFar = Double(radius * 20)
camera.position = SCNVector3(center.x + radius * 0.65,
center.y + radius * 0.7, center.z + radius * 3)
camera.look(at: center)
scene.rootNode.addChildNode(camera)
let ambient = SCNNode()
ambient.light = SCNLight()
ambient.light?.type = .ambient
ambient.light?.intensity = 300
scene.rootNode.addChildNode(ambient)
let light = SCNNode()
light.light = SCNLight()
light.light?.type = .omni
light.light?.intensity = 800
light.position = SCNVector3(center.x - radius, center.y + radius * 3, center.z + radius * 2)
scene.rootNode.addChildNode(light)
let renderer = SCNRenderer(device: nil, options: nil)
renderer.scene = scene
renderer.pointOfView = camera
let image = renderer.snapshot(atTime: 0, with: CGSize(width: 600, height: 360),
antialiasingMode: .multisampling4X)
cache.setObject(image, forKey: key)
return image
}
}