Add lead workflows, PDF signing, AR projects and photo gallery integration
This commit is contained in:
22
GasTankAR/Services/AuthenticationService.swift
Normal file
22
GasTankAR/Services/AuthenticationService.swift
Normal 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
|
||||
}
|
||||
28
GasTankAR/Services/CustomerLocationService.swift
Normal file
28
GasTankAR/Services/CustomerLocationService.swift
Normal 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
|
||||
}
|
||||
}
|
||||
30
GasTankAR/Services/LoginUseCase.swift
Normal file
30
GasTankAR/Services/LoginUseCase.swift
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
76
GasTankAR/Services/Signing/DefaultPDFSigningService.swift
Normal file
76
GasTankAR/Services/Signing/DefaultPDFSigningService.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
47
GasTankAR/Services/Signing/LeadSignedDocumentStore.swift
Normal file
47
GasTankAR/Services/Signing/LeadSignedDocumentStore.swift
Normal 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
|
||||
}
|
||||
}
|
||||
27
GasTankAR/Services/Signing/LeadSignedPDFUploadService.swift
Normal file
27
GasTankAR/Services/Signing/LeadSignedPDFUploadService.swift
Normal 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
|
||||
}
|
||||
}
|
||||
26
GasTankAR/Services/Signing/PDFSigningService.swift
Normal file
26
GasTankAR/Services/Signing/PDFSigningService.swift
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
21
GasTankAR/Services/StubAuthenticationService.swift
Normal file
21
GasTankAR/Services/StubAuthenticationService.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
71
GasTankAR/Services/TankThumbnailRenderer.swift
Normal file
71
GasTankAR/Services/TankThumbnailRenderer.swift
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user