Add lead workflows, PDF signing, AR projects and photo gallery integration
This commit is contained in:
91
GasTankAR/Data/Leads/DefaultLeadDetailRepository.swift
Normal file
91
GasTankAR/Data/Leads/DefaultLeadDetailRepository.swift
Normal file
@ -0,0 +1,91 @@
|
||||
import Foundation
|
||||
import PDFKit
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
final class DefaultLeadDetailRepository: LeadDetailRepository {
|
||||
private struct FileKey: Hashable {
|
||||
let reference: LeadFileReference
|
||||
let leadID: String
|
||||
let salesCode: String
|
||||
}
|
||||
|
||||
private let remoteService: any LeadDetailRemoteService
|
||||
private let cacheDirectory: URL
|
||||
private var cachedFiles: [FileKey: URL] = [:]
|
||||
private var downloads: [FileKey: Task<URL, Error>] = [:]
|
||||
|
||||
init(remoteService: any LeadDetailRemoteService,
|
||||
cacheDirectory: URL = FileManager.default.temporaryDirectory.appendingPathComponent("LeadFiles")) {
|
||||
self.remoteService = remoteService
|
||||
self.cacheDirectory = cacheDirectory
|
||||
}
|
||||
|
||||
func detail(id: String, salesCode: String) async throws -> LeadDetail {
|
||||
do {
|
||||
return try await remoteService.fetchDetail(id: id, salesCode: salesCode).toDomain(expectedID: id)
|
||||
} catch {
|
||||
throw DefaultLeadRepository.map(error, leadID: id)
|
||||
}
|
||||
}
|
||||
|
||||
func file(_ reference: LeadFileReference, leadID: String, salesCode: String) async throws -> URL {
|
||||
try Task.checkCancellation()
|
||||
let key = FileKey(reference: reference, leadID: leadID, salesCode: salesCode)
|
||||
if let url = cachedFiles[key], FileManager.default.fileExists(atPath: url.path) {
|
||||
return url
|
||||
}
|
||||
if let download = downloads[key] {
|
||||
let url = try await download.value
|
||||
try Task.checkCancellation()
|
||||
return url
|
||||
}
|
||||
let directory = cacheDirectory
|
||||
let service = remoteService
|
||||
// A shared download survives one viewer closing; that viewer checks cancellation below.
|
||||
let download = Task {
|
||||
let data: Data
|
||||
do {
|
||||
data = try await service.download(reference, leadID: leadID, salesCode: salesCode)
|
||||
} catch LeadRemoteError.http(404) {
|
||||
throw LeadFileError.notFound
|
||||
} catch {
|
||||
throw DefaultLeadRepository.map(error)
|
||||
}
|
||||
return try await Task.detached {
|
||||
try Self.persist(data, kind: reference.kind, directory: directory)
|
||||
}.value
|
||||
}
|
||||
downloads[key] = download
|
||||
do {
|
||||
let url = try await download.value
|
||||
downloads[key] = nil
|
||||
cachedFiles[key] = url
|
||||
try Task.checkCancellation()
|
||||
return url
|
||||
} catch {
|
||||
downloads[key] = nil
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private static func persist(_ data: Data, kind: LeadFileKind, directory: URL) throws -> URL {
|
||||
switch kind {
|
||||
case .pdf:
|
||||
guard let document = PDFDocument(data: data), document.pageCount > 0, !document.isLocked else {
|
||||
throw LeadFileError.invalidFile
|
||||
}
|
||||
case .image:
|
||||
guard UIImage(data: data) != nil else { throw LeadFileError.invalidFile }
|
||||
}
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
let url = directory.appendingPathComponent(UUID().uuidString)
|
||||
.appendingPathExtension(kind == .pdf ? "pdf" : "png")
|
||||
try data.write(to: url, options: .atomic)
|
||||
return url
|
||||
} catch {
|
||||
throw LeadFileError.storageUnavailable
|
||||
}
|
||||
}
|
||||
}
|
||||
68
GasTankAR/Data/Leads/DefaultLeadRepository.swift
Normal file
68
GasTankAR/Data/Leads/DefaultLeadRepository.swift
Normal file
@ -0,0 +1,68 @@
|
||||
import Foundation
|
||||
|
||||
struct DefaultLeadRepository: LeadRepository {
|
||||
let remoteService: any LeadRemoteService
|
||||
|
||||
func leads(for salesCode: String, query: LeadQuery) async throws -> [Lead] {
|
||||
do {
|
||||
let values = try await remoteService.fetchLeads(salesCode: salesCode, query: query)
|
||||
let leads = try values.map { try $0.toDomain() }
|
||||
guard Set(leads.map(\.id)).count == leads.count else {
|
||||
throw LeadError.invalidResponse
|
||||
}
|
||||
return leads
|
||||
} catch {
|
||||
throw Self.map(error)
|
||||
}
|
||||
}
|
||||
|
||||
func lead(id: String, salesCode: String) async throws -> Lead {
|
||||
do {
|
||||
let value = try await remoteService.fetchLead(id: id, salesCode: salesCode)
|
||||
guard value.id == id else { throw LeadError.invalidResponse }
|
||||
return try value.toDomain()
|
||||
} catch {
|
||||
throw Self.map(error, leadID: id)
|
||||
}
|
||||
}
|
||||
|
||||
func setBookmark(_ bookmarked: Bool, leadID: String, salesCode: String) async throws -> Lead {
|
||||
do {
|
||||
let value = try await remoteService.setBookmark(bookmarked, id: leadID, salesCode: salesCode)
|
||||
guard value.id == leadID, value.isBookmarked == bookmarked else {
|
||||
throw LeadError.invalidResponse
|
||||
}
|
||||
return try value.toDomain()
|
||||
} catch {
|
||||
throw Self.map(error, leadID: leadID)
|
||||
}
|
||||
}
|
||||
|
||||
static func map(_ error: Error, leadID: String? = nil) -> Error {
|
||||
if error is CancellationError { return CancellationError() }
|
||||
if let error = error as? LeadError { return error }
|
||||
if let error = error as? URLError {
|
||||
switch error.code {
|
||||
case .cancelled: return CancellationError()
|
||||
case .notConnectedToInternet, .networkConnectionLost: return LeadError.offline
|
||||
case .timedOut: return LeadError.timeout
|
||||
case .cannotFindHost, .cannotConnectToHost, .dnsLookupFailed:
|
||||
return LeadError.serviceUnavailable
|
||||
default: return LeadError.unexpected
|
||||
}
|
||||
}
|
||||
if let error = error as? LeadRemoteError {
|
||||
switch error {
|
||||
case .http(401): return LeadError.unauthorized
|
||||
case .http(403): return LeadError.forbidden
|
||||
case .http(404):
|
||||
return leadID.map { LeadError.notFound(leadID: $0) } ?? .serviceUnavailable
|
||||
case .http(let status) where status >= 500: return LeadError.serviceUnavailable
|
||||
case .malformedResponse: return LeadError.invalidResponse
|
||||
default: return LeadError.unexpected
|
||||
}
|
||||
}
|
||||
if error is DecodingError { return LeadError.invalidResponse }
|
||||
return LeadError.unexpected
|
||||
}
|
||||
}
|
||||
27
GasTankAR/Data/Leads/LeadARPhotoStore.swift
Normal file
27
GasTankAR/Data/Leads/LeadARPhotoStore.swift
Normal file
@ -0,0 +1,27 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// Reads project photos through the lead association in a fresh context after AR closes.
|
||||
@MainActor
|
||||
final class LeadARPhotoStore {
|
||||
private let context: ModelContext
|
||||
|
||||
init(container: ModelContainer) {
|
||||
context = ModelContext(container)
|
||||
context.autosaveEnabled = false
|
||||
}
|
||||
|
||||
func photos(for leadID: String) throws -> [ProjectPhoto] {
|
||||
let links = try context.fetch(FetchDescriptor<LeadARProjectLink>(
|
||||
predicate: #Predicate { $0.leadID == leadID }))
|
||||
let legacy = try context.fetch(FetchDescriptor<Project>(
|
||||
predicate: #Predicate { $0.leadID == leadID }))
|
||||
var projects = links.compactMap(\.project)
|
||||
let linkedIDs = Set(projects.map(\.projectID))
|
||||
projects.append(contentsOf: legacy.filter { !linkedIDs.contains($0.projectID) })
|
||||
var seen = Set<PersistentIdentifier>()
|
||||
return projects.flatMap(\.photos)
|
||||
.filter { seen.insert($0.persistentModelID).inserted }
|
||||
.sorted { $0.creationDate > $1.creationDate }
|
||||
}
|
||||
}
|
||||
159
GasTankAR/Data/Leads/LeadARProjectsService.swift
Normal file
159
GasTankAR/Data/Leads/LeadARProjectsService.swift
Normal file
@ -0,0 +1,159 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// Translates sales data into AR configurations and owns the persistent association.
|
||||
/// A separate context makes a failed save atomic without rolling back unrelated UI edits.
|
||||
@MainActor
|
||||
final class LeadARProjectsService: ARProjectsProviding {
|
||||
private let detail: LeadDetail
|
||||
private let context: ModelContext
|
||||
private let catalog: any TankCatalogProviding
|
||||
|
||||
init(detail: LeadDetail, container: ModelContainer,
|
||||
catalog: any TankCatalogProviding = TankCatalog.shared) {
|
||||
self.detail = detail
|
||||
self.context = ModelContext(container)
|
||||
self.context.autosaveEnabled = false
|
||||
self.catalog = catalog
|
||||
}
|
||||
|
||||
func loadProjects() throws -> [ARProject] {
|
||||
do {
|
||||
let leadID = detail.lead.id
|
||||
var links = try context.fetch(FetchDescriptor<LeadARProjectLink>(
|
||||
predicate: #Predicate { $0.leadID == leadID }))
|
||||
let legacy = try context.fetch(FetchDescriptor<Project>(
|
||||
predicate: #Predicate { $0.leadID == leadID },
|
||||
sortBy: [SortDescriptor(\.creationDate)]))
|
||||
var linkedIDs = Set(links.compactMap { $0.project?.projectID })
|
||||
|
||||
for offer in detail.offers {
|
||||
guard let tank = offer.tank else { continue }
|
||||
guard catalog.definition(for: tank) != nil else { throw ProjectError.unavailableTank }
|
||||
guard !links.contains(where: { $0.offerID == offer.id && $0.project != nil }) else { continue }
|
||||
// Reuse an unlinked legacy session once, preserving world maps, photos and routes.
|
||||
let project = legacy.first { !linkedIDs.contains($0.projectID) && $0.tank == tank }
|
||||
?? makeProject(name: offer.title, tank: tank)
|
||||
if project.projectName.isEmpty { project.projectName = offer.title }
|
||||
let link = LeadARProjectLink(leadID: leadID, offerID: offer.id, project: project)
|
||||
context.insert(link)
|
||||
links.append(link)
|
||||
linkedIDs.insert(project.projectID)
|
||||
}
|
||||
|
||||
for project in legacy where !linkedIDs.contains(project.projectID) {
|
||||
if project.projectName.isEmpty { project.projectName = "Mein AR-Projekt" }
|
||||
let link = LeadARProjectLink(leadID: leadID, project: project)
|
||||
context.insert(link)
|
||||
links.append(link)
|
||||
}
|
||||
try context.save()
|
||||
return links.compactMap(projectValue).sorted {
|
||||
if $0.source != $1.source { return $0.source == .supplied }
|
||||
return $0.createdAt > $1.createdAt
|
||||
}
|
||||
} catch {
|
||||
context.rollback()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func createProject(name: String, tank: Tank) throws -> ARProject {
|
||||
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { throw ProjectError.missingName }
|
||||
guard catalog.definition(for: tank) != nil else { throw ProjectError.unavailableTank }
|
||||
do {
|
||||
let project = makeProject(name: trimmed, tank: tank)
|
||||
let link = LeadARProjectLink(leadID: detail.lead.id, project: project)
|
||||
context.insert(link)
|
||||
try context.save()
|
||||
return ARProject(id: project.projectID, name: trimmed, tank: tank,
|
||||
createdAt: project.creationDate, source: .custom, reference: nil)
|
||||
} catch {
|
||||
context.rollback()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func updateProject(id: UUID, name: String, tank: Tank) throws -> ARProject {
|
||||
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { throw ProjectError.missingName }
|
||||
guard catalog.definition(for: tank) != nil else { throw ProjectError.unavailableTank }
|
||||
do {
|
||||
let leadID = detail.lead.id
|
||||
let links = try context.fetch(FetchDescriptor<LeadARProjectLink>(
|
||||
predicate: #Predicate { $0.leadID == leadID }))
|
||||
let matching = links.filter { $0.project?.projectID == id }
|
||||
guard let project = matching.first?.project else { throw ProjectError.notFound }
|
||||
guard matching.allSatisfy({ $0.offerID == nil }) else { throw ProjectError.editingNotAllowed }
|
||||
project.projectName = trimmed
|
||||
project.tank = tank
|
||||
try context.save()
|
||||
return ARProject(id: project.projectID, name: trimmed, tank: tank,
|
||||
createdAt: project.creationDate, source: .custom, reference: nil)
|
||||
} catch {
|
||||
context.rollback()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func deleteProject(id: UUID) throws {
|
||||
do {
|
||||
let leadID = detail.lead.id
|
||||
let links = try context.fetch(FetchDescriptor<LeadARProjectLink>(
|
||||
predicate: #Predicate { $0.leadID == leadID }))
|
||||
let matching = links.filter { $0.project?.projectID == id }
|
||||
guard let project = matching.first?.project else { throw ProjectError.notFound }
|
||||
guard matching.allSatisfy({ $0.offerID == nil }) else {
|
||||
throw ProjectError.deletionNotAllowed
|
||||
}
|
||||
for link in matching {
|
||||
link.project = nil
|
||||
context.delete(link)
|
||||
}
|
||||
context.delete(project)
|
||||
try context.save()
|
||||
} catch {
|
||||
context.rollback()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func resolveProject(id: UUID) throws -> Project {
|
||||
let leadID = detail.lead.id
|
||||
let links = try context.fetch(FetchDescriptor<LeadARProjectLink>(
|
||||
predicate: #Predicate { $0.leadID == leadID }))
|
||||
guard let project = links.compactMap(\.project).first(where: { $0.projectID == id })
|
||||
else { throw ProjectError.notFound }
|
||||
return project
|
||||
}
|
||||
|
||||
private func makeProject(name: String, tank: Tank) -> Project {
|
||||
// These compatibility fields are still used by the existing AR photo/export flow.
|
||||
let project = Project(leadID: detail.lead.id, customerName: detail.lead.displayName,
|
||||
creationDate: .now, tank: tank)
|
||||
project.projectName = name
|
||||
context.insert(project)
|
||||
return project
|
||||
}
|
||||
|
||||
private func projectValue(_ link: LeadARProjectLink) -> ARProject? {
|
||||
guard let project = link.project else { return nil }
|
||||
return ARProject(id: project.projectID, name: project.projectName,
|
||||
tank: project.tank, createdAt: project.creationDate,
|
||||
source: link.offerID == nil ? .custom : .supplied, reference: link.offerID)
|
||||
}
|
||||
|
||||
enum ProjectError: LocalizedError {
|
||||
case unavailableTank, missingName, notFound, deletionNotAllowed, editingNotAllowed
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .unavailableTank: "Für diese Tankkonfiguration ist kein Modell verfügbar."
|
||||
case .missingName: "Bitte gib einen Projektnamen ein."
|
||||
case .deletionNotAllowed: "Bereitgestellte Projekte können nicht gelöscht werden."
|
||||
case .editingNotAllowed: "Bereitgestellte Projekte können nicht bearbeitet werden."
|
||||
case .notFound: "Das Projekt konnte nicht gefunden werden."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
29
GasTankAR/Data/Leads/LeadDetailRemoteService.swift
Normal file
29
GasTankAR/Data/Leads/LeadDetailRemoteService.swift
Normal file
@ -0,0 +1,29 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
protocol LeadDetailRemoteService {
|
||||
func fetchDetail(id: String, salesCode: String) async throws -> LeadDetailDTO
|
||||
func download(_ reference: LeadFileReference, leadID: String, salesCode: String) async throws -> Data
|
||||
}
|
||||
|
||||
/// Transport boundary. A future HTTP adapter maps its response into this payload.
|
||||
struct LeadDetailDTO {
|
||||
let detail: LeadDetail
|
||||
|
||||
func toDomain(expectedID: String) throws -> LeadDetail {
|
||||
let documents = detail.offers.flatMap(\.documents)
|
||||
let groups = detail.offers.flatMap(\.groups)
|
||||
guard detail.lead.id == expectedID,
|
||||
Set(detail.offers.map(\.id)).count == detail.offers.count,
|
||||
Set(groups.map(\.id)).count == groups.count,
|
||||
Set(documents.map(\.id)).count == documents.count,
|
||||
Set(detail.photos.map(\.id)).count == detail.photos.count,
|
||||
Set(detail.tankOptions.map(\.id)).count == detail.tankOptions.count,
|
||||
detail.offers.allSatisfy({ !$0.id.isEmpty && !$0.title.isEmpty && $0.amount >= 0 }),
|
||||
documents.allSatisfy({ !$0.id.isEmpty && !$0.title.isEmpty && $0.pageCount > 0
|
||||
&& $0.file.kind == .pdf && !$0.file.id.isEmpty && !$0.file.version.isEmpty }),
|
||||
detail.photos.allSatisfy({ $0.thumbnail.kind == .image && $0.original.kind == .image })
|
||||
else { throw LeadError.invalidResponse }
|
||||
return detail
|
||||
}
|
||||
}
|
||||
92
GasTankAR/Data/Leads/LeadDetailSampleData.swift
Normal file
92
GasTankAR/Data/Leads/LeadDetailSampleData.swift
Normal file
@ -0,0 +1,92 @@
|
||||
import Foundation
|
||||
import CoreGraphics
|
||||
|
||||
enum LeadDetailSampleData {
|
||||
static let tanks = [
|
||||
LeadTankOption(id: "2700", title: "2.700 l", tank: Tank(size: .t1_2)),
|
||||
LeadTankOption(id: "4850", title: "4.850 l", tank: Tank(size: .t2_1)),
|
||||
LeadTankOption(id: "underground", title: "Unterirdisch",
|
||||
tank: Tank(size: .t1_2, color: .green, placement: .belowGround))
|
||||
]
|
||||
|
||||
static func make(for lead: Lead) -> LeadDetail {
|
||||
let isHoffmann = lead.id == "L-240318"
|
||||
let offers = isHoffmann ? hoffmannOffers(leadID: lead.id) : genericOffers(for: lead)
|
||||
let photos = isHoffmann ? (1...10).map { index in
|
||||
LeadPhoto(id: "\(lead.id)-photo-\(index)", title: "Standortaufnahme \(index)", isAR: index < 3,
|
||||
thumbnail: LeadFileReference(id: "\(lead.id)-thumb-\(index)", version: "1", kind: .image),
|
||||
original: LeadFileReference(id: "\(lead.id)-image-\(index)", version: "1", kind: .image))
|
||||
} : []
|
||||
let name = lead.contact.map { [$0.title, $0.firstName, $0.lastName].compactMap { $0 }.joined(separator: " ") }
|
||||
return LeadDetail(lead: lead, customerType: lead.companyName == nil ? "Privatkunde" : "Gewerbekunde",
|
||||
contactName: isHoffmann ? "Klaus Hoffmann" : name,
|
||||
contactRole: isHoffmann ? "Geschäftsführer" : nil,
|
||||
mobile: isHoffmann ? "0171 338 4206" : nil,
|
||||
email: isHoffmann ? "info@baeckerei-hoffmann.de" : nil,
|
||||
tankOptions: tanks, offers: offers, photos: photos)
|
||||
}
|
||||
|
||||
private static func document(_ id: String, _ title: String, pages: Int, signed: Bool = false) -> LeadDocument {
|
||||
LeadDocument(id: id, title: title, pageCount: pages, status: signed ? .signed : .pending,
|
||||
file: LeadFileReference(id: id, version: "signature-preview-1", kind: .pdf),
|
||||
signaturePlacement: PDFSignaturePlacement(
|
||||
pageIndex: 0, bounds: CGRect(x: 310, y: 76, width: 237, height: 60)))
|
||||
}
|
||||
|
||||
private static func date(_ month: Int, _ day: Int) -> Date? {
|
||||
Calendar(identifier: .gregorian).date(from: DateComponents(year: 2026, month: month, day: day, hour: 12))
|
||||
}
|
||||
|
||||
private static func hoffmannOffers(leadID: String) -> [LeadOffer] {
|
||||
let prefix = "\(leadID)-"
|
||||
let contracts = LeadDocumentGroup(id: prefix + "contracts", title: "Vertragsunterlagen", documents: [
|
||||
document(prefix + "purchase", "Kaufvertrag Flüssiggastank.pdf", pages: 4),
|
||||
document(prefix + "gas", "Gasliefervertrag.pdf", pages: 6),
|
||||
document(prefix + "sepa", "SEPA-Lastschriftmandat.pdf", pages: 1)
|
||||
])
|
||||
let technical = LeadDocumentGroup(id: prefix + "technical", title: "Technik & Sicherheit", documents: [
|
||||
document(prefix + "plan", "Aufstellungsplan Tank.pdf", pages: 2),
|
||||
document(prefix + "safety", "Sicherheitsdatenblatt Propan.pdf", pages: 3, signed: true)
|
||||
])
|
||||
let privacy = LeadDocumentGroup(id: prefix + "privacy", title: "Datenschutz", documents: [
|
||||
document(prefix + "consent", "Datenschutzeinwilligung.pdf", pages: 1, signed: true)
|
||||
])
|
||||
let rental = LeadDocumentGroup(id: prefix + "rental", title: "Vertragsunterlagen", documents: [
|
||||
document(prefix + "rental-contract", "Mietvertrag Flüssiggastank.pdf", pages: 5),
|
||||
document(prefix + "rental-gas", "Gasliefervertrag.pdf", pages: 6),
|
||||
document(prefix + "rental-sepa", "SEPA-Lastschriftmandat.pdf", pages: 1),
|
||||
document(prefix + "rental-plan", "Aufstellungsplan.pdf", pages: 2),
|
||||
document(prefix + "rental-privacy", "Datenschutzeinwilligung.pdf", pages: 1)
|
||||
])
|
||||
return [
|
||||
LeadOffer(id: "A-2026-1182", title: "Flüssiggastank 2.700 l oberirdisch", kind: .purchase,
|
||||
amount: 4980, billing: .once, validUntil: date(11, 30), acceptedAt: nil,
|
||||
groups: [contracts, technical, privacy], tank: Tank(size: .t1_2)),
|
||||
LeadOffer(id: "A-2026-1197", title: "Flüssiggastank 4.850 l unterirdisch", kind: .rental,
|
||||
amount: 39, billing: .monthly, validUntil: date(12, 15), acceptedAt: nil, groups: [rental],
|
||||
tank: Tank(size: .t2_1, color: .olive, placement: .belowGround)),
|
||||
LeadOffer(id: "A-2026-1043", title: "Wartungsvertrag Gasanlage", kind: .service,
|
||||
amount: 129, billing: .annually, validUntil: nil, acceptedAt: date(10, 30),
|
||||
groups: [LeadDocumentGroup(id: prefix + "service", title: "Vertragsunterlagen", documents: [
|
||||
document(prefix + "maintenance", "Wartungsvertrag Gasanlage.pdf", pages: 3, signed: true)
|
||||
])])
|
||||
]
|
||||
}
|
||||
|
||||
private static func genericOffers(for lead: Lead) -> [LeadOffer] {
|
||||
guard lead.offerCount > 0 else { return [] }
|
||||
return (0..<lead.offerCount).map { index in
|
||||
let count = lead.unsignedContractCount / lead.offerCount
|
||||
+ (index < lead.unsignedContractCount % lead.offerCount ? 1 : 0)
|
||||
let documents = (0..<max(1, count)).map { number in
|
||||
document("\(lead.id)-offer-\(index)-doc-\(number)", "Vertragsunterlage \(number + 1).pdf",
|
||||
pages: 2, signed: count == 0)
|
||||
}
|
||||
return LeadOffer(id: "\(lead.id)-A-\(index + 1)", title: "Flüssiggastank 2.700 l oberirdisch",
|
||||
kind: .purchase, amount: 4980, billing: .once,
|
||||
validUntil: date(11, 30), acceptedAt: nil,
|
||||
groups: [LeadDocumentGroup(id: "\(lead.id)-group-\(index)",
|
||||
title: "Vertragsunterlagen", documents: documents)], tank: Tank(size: .t1_2))
|
||||
}
|
||||
}
|
||||
}
|
||||
58
GasTankAR/Data/Leads/LeadRemoteService.swift
Normal file
58
GasTankAR/Data/Leads/LeadRemoteService.swift
Normal file
@ -0,0 +1,58 @@
|
||||
import Foundation
|
||||
|
||||
/// Transport boundary. A future URLSession implementation must enforce a finite
|
||||
/// request timeout and throw HTTP errors before decoding the response body.
|
||||
@MainActor
|
||||
protocol LeadRemoteService {
|
||||
func fetchLeads(salesCode: String, query: LeadQuery) async throws -> [LeadDTO]
|
||||
func fetchLead(id: String, salesCode: String) async throws -> LeadDTO
|
||||
func setBookmark(_ bookmarked: Bool, id: String, salesCode: String) async throws -> LeadDTO
|
||||
}
|
||||
|
||||
enum LeadRemoteError: Error {
|
||||
case http(statusCode: Int)
|
||||
case malformedResponse
|
||||
}
|
||||
|
||||
/// Provisional transport shape, independent of the UI and domain model.
|
||||
struct LeadDTO: Codable, Sendable {
|
||||
let id: String
|
||||
let companyName: String?
|
||||
let contactTitle: String?
|
||||
let firstName: String?
|
||||
let lastName: String?
|
||||
let street: String?
|
||||
let postalCode: String
|
||||
let city: String
|
||||
let phoneNumber: String?
|
||||
let createdAt: Date
|
||||
let offerCount: Int
|
||||
let unsignedContractCount: Int
|
||||
var isBookmarked: Bool
|
||||
|
||||
func toDomain() throws -> Lead {
|
||||
func clean(_ value: String?) -> String? {
|
||||
guard let text = value?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!text.isEmpty else { return nil }
|
||||
return text
|
||||
}
|
||||
let company = clean(companyName)
|
||||
let first = clean(firstName)
|
||||
let last = clean(lastName)
|
||||
guard clean(id) != nil, company != nil || first != nil || last != nil,
|
||||
clean(postalCode) != nil, clean(city) != nil,
|
||||
offerCount >= 0, unsignedContractCount >= 0 else {
|
||||
throw LeadError.invalidResponse
|
||||
}
|
||||
let contact = first != nil || last != nil
|
||||
? LeadContact(title: clean(contactTitle), firstName: first ?? "", lastName: last ?? "")
|
||||
: nil
|
||||
return Lead(
|
||||
id: id, companyName: company, contact: contact,
|
||||
address: LeadAddress(street: clean(street), postalCode: postalCode, city: city),
|
||||
phoneNumber: clean(phoneNumber), createdAt: createdAt,
|
||||
offerCount: offerCount, unsignedContractCount: unsignedContractCount,
|
||||
isBookmarked: isBookmarked
|
||||
)
|
||||
}
|
||||
}
|
||||
111
GasTankAR/Data/Leads/StubLeadDetailRemoteService.swift
Normal file
111
GasTankAR/Data/Leads/StubLeadDetailRemoteService.swift
Normal file
@ -0,0 +1,111 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
final class StubLeadDetailRemoteService: LeadDetailRemoteService {
|
||||
enum Scenario { case success, offline, timeout, http(Int), invalidFile }
|
||||
var detailScenario: Scenario = .success
|
||||
var fileScenario: Scenario = .success
|
||||
var delay: Duration
|
||||
private(set) var downloadCount = 0
|
||||
private let leads: any LeadRemoteService
|
||||
|
||||
init(leads: (any LeadRemoteService)? = nil,
|
||||
delay: Duration = .milliseconds(400)) {
|
||||
self.leads = leads ?? StubLeadRemoteService(delay: .zero)
|
||||
self.delay = delay
|
||||
}
|
||||
|
||||
func fetchDetail(id: String, salesCode: String) async throws -> LeadDetailDTO {
|
||||
try await simulate(detailScenario)
|
||||
let lead = try await leads.fetchLead(id: id, salesCode: salesCode).toDomain()
|
||||
return LeadDetailDTO(detail: LeadDetailSampleData.make(for: lead))
|
||||
}
|
||||
|
||||
func download(_ reference: LeadFileReference, leadID: String, salesCode: String) async throws -> Data {
|
||||
downloadCount += 1
|
||||
try await simulate(fileScenario)
|
||||
if case .invalidFile = fileScenario { return Data("not a valid file".utf8) }
|
||||
let lead = try await leads.fetchLead(id: leadID, salesCode: salesCode).toDomain()
|
||||
let detail = LeadDetailSampleData.make(for: lead)
|
||||
switch reference.kind {
|
||||
case .pdf:
|
||||
guard let document = detail.offers.flatMap(\.documents).first(where: { $0.file == reference }) else {
|
||||
throw LeadRemoteError.http(statusCode: 404)
|
||||
}
|
||||
return makePDF(document: document, customer: lead.displayName)
|
||||
case .image:
|
||||
guard let photo = detail.photos.first(where: { $0.thumbnail == reference || $0.original == reference }) else {
|
||||
throw LeadRemoteError.http(statusCode: 404)
|
||||
}
|
||||
return makeImage(photo: photo, thumbnail: reference == photo.thumbnail)
|
||||
}
|
||||
}
|
||||
|
||||
private func simulate(_ scenario: Scenario) async throws {
|
||||
try await Task.sleep(for: delay)
|
||||
try Task.checkCancellation()
|
||||
switch scenario {
|
||||
case .success, .invalidFile: break
|
||||
case .offline: throw URLError(.notConnectedToInternet)
|
||||
case .timeout: throw URLError(.timedOut)
|
||||
case .http(let code): throw LeadRemoteError.http(statusCode: code)
|
||||
}
|
||||
}
|
||||
|
||||
private func makePDF(document: LeadDocument, customer: String) -> Data {
|
||||
let renderer = UIGraphicsPDFRenderer(bounds: CGRect(x: 0, y: 0, width: 595, height: 842))
|
||||
return renderer.pdfData { context in
|
||||
for page in 1...document.pageCount {
|
||||
context.beginPage()
|
||||
let title = document.title.replacingOccurrences(of: ".pdf", with: "")
|
||||
(title as NSString).draw(in: CGRect(x: 48, y: 56, width: 499, height: 100),
|
||||
withAttributes: [.font: UIFont.boldSystemFont(ofSize: 24)])
|
||||
let body = """
|
||||
DrachenGas – Beispieldokument
|
||||
\(customer)
|
||||
|
||||
Seite \(page) von \(document.pageCount)
|
||||
|
||||
Diese Datei dient ausschließlich zur Vorschau der Download- und PDF-Anzeige.
|
||||
Sie enthält keine verbindlichen Vertragsbedingungen.
|
||||
|
||||
Bitte prüfen Sie alle Seiten vor der Unterschrift.
|
||||
"""
|
||||
(body as NSString).draw(in: CGRect(x: 48, y: 180, width: 499, height: 540),
|
||||
withAttributes: [.font: UIFont.systemFont(ofSize: 15),
|
||||
.foregroundColor: UIColor.darkGray])
|
||||
if let placement = document.signaturePlacement, placement.pageIndex == page - 1 {
|
||||
let bounds = placement.bounds
|
||||
let field = CGRect(x: bounds.minX, y: 842 - bounds.maxY,
|
||||
width: bounds.width, height: bounds.height)
|
||||
UIColor.lightGray.setStroke()
|
||||
UIBezierPath(rect: field).stroke()
|
||||
("Unterschrift Kunde" as NSString).draw(
|
||||
at: CGPoint(x: field.minX, y: field.maxY + 8),
|
||||
withAttributes: [.font: UIFont.systemFont(ofSize: 11),
|
||||
.foregroundColor: UIColor.darkGray]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func makeImage(photo: LeadPhoto, thumbnail: Bool) -> Data {
|
||||
let size = thumbnail ? CGSize(width: 360, height: 220) : CGSize(width: 1200, height: 800)
|
||||
let renderer = UIGraphicsImageRenderer(size: size)
|
||||
return renderer.pngData { context in
|
||||
UIColor.systemTeal.withAlphaComponent(0.14).setFill()
|
||||
context.fill(CGRect(origin: .zero, size: size))
|
||||
let symbol = UIImage(systemName: photo.isAR ? "arkit" : "photo")?
|
||||
.withTintColor(.systemBlue, renderingMode: .alwaysOriginal)
|
||||
symbol?.draw(in: CGRect(x: size.width * 0.4, y: size.height * 0.2,
|
||||
width: size.width * 0.2, height: size.height * 0.35))
|
||||
("Beispiel · \(photo.title)" as NSString).draw(
|
||||
in: CGRect(x: 20, y: size.height * 0.72, width: size.width - 40, height: 60),
|
||||
withAttributes: [.font: UIFont.systemFont(ofSize: thumbnail ? 16 : 32),
|
||||
.foregroundColor: UIColor.darkGray]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
129
GasTankAR/Data/Leads/StubLeadRemoteService.swift
Normal file
129
GasTankAR/Data/Leads/StubLeadRemoteService.swift
Normal file
@ -0,0 +1,129 @@
|
||||
import Foundation
|
||||
|
||||
/// In-memory server substitute. Bookmarks are isolated by sales code.
|
||||
/// All error scenarios can be injected without special user-facing search strings.
|
||||
@MainActor
|
||||
final class StubLeadRemoteService: LeadRemoteService {
|
||||
enum Scenario {
|
||||
case success, offline, timeout, http(Int), malformedResponse
|
||||
}
|
||||
|
||||
var scenario: Scenario
|
||||
var delay: Duration
|
||||
var listDelay: Duration?
|
||||
private var records: [LeadDTO]
|
||||
private var bookmarksByUser: [String: Set<String>] = [:]
|
||||
|
||||
init(scenario: Scenario = .success, delay: Duration = .milliseconds(350),
|
||||
records: [LeadDTO] = LeadSampleData.records, listDelay: Duration? = nil) {
|
||||
self.scenario = scenario
|
||||
self.delay = delay
|
||||
self.listDelay = listDelay
|
||||
self.records = records
|
||||
}
|
||||
|
||||
func fetchLeads(salesCode: String, query: LeadQuery) async throws -> [LeadDTO] {
|
||||
try await simulate(delay: listDelay)
|
||||
let terms = query.normalized.searchText.split(whereSeparator: \.isWhitespace).map(String.init)
|
||||
var result = records.map { personalized($0, salesCode: salesCode) }.filter { lead in
|
||||
let fields = [lead.id, lead.companyName, lead.contactTitle, lead.firstName,
|
||||
lead.lastName, lead.street, lead.postalCode, lead.city]
|
||||
.compactMap { $0 }.joined(separator: " ")
|
||||
return terms.allSatisfy { fields.localizedStandardContains($0) }
|
||||
&& (query.filter == .all || lead.unsignedContractCount > 0)
|
||||
}
|
||||
result.sort {
|
||||
switch query.sort {
|
||||
case .newest:
|
||||
return $0.createdAt == $1.createdAt ? $0.id < $1.id : $0.createdAt > $1.createdAt
|
||||
case .nameAscending:
|
||||
let comparison = name($0).localizedStandardCompare(name($1))
|
||||
return comparison == .orderedSame ? $0.id < $1.id : comparison == .orderedAscending
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func fetchLead(id: String, salesCode: String) async throws -> LeadDTO {
|
||||
try await simulate()
|
||||
guard let record = records.first(where: { $0.id == id }) else {
|
||||
throw LeadRemoteError.http(statusCode: 404)
|
||||
}
|
||||
return personalized(record, salesCode: salesCode)
|
||||
}
|
||||
|
||||
func setBookmark(_ bookmarked: Bool, id: String, salesCode: String) async throws -> LeadDTO {
|
||||
try await simulate()
|
||||
guard let record = records.first(where: { $0.id == id }) else {
|
||||
throw LeadRemoteError.http(statusCode: 404)
|
||||
}
|
||||
var bookmarks = bookmarks(for: salesCode)
|
||||
if bookmarked { bookmarks.insert(id) } else { bookmarks.remove(id) }
|
||||
bookmarksByUser[salesCode] = bookmarks
|
||||
return personalized(record, salesCode: salesCode)
|
||||
}
|
||||
|
||||
private func simulate(delay override: Duration? = nil) async throws {
|
||||
try await Task.sleep(for: override ?? delay)
|
||||
try Task.checkCancellation()
|
||||
switch scenario {
|
||||
case .success: break
|
||||
case .offline: throw URLError(.notConnectedToInternet)
|
||||
case .timeout: throw URLError(.timedOut)
|
||||
case .http(let status): throw LeadRemoteError.http(statusCode: status)
|
||||
case .malformedResponse: throw LeadRemoteError.malformedResponse
|
||||
}
|
||||
}
|
||||
|
||||
private func bookmarks(for salesCode: String) -> Set<String> {
|
||||
bookmarksByUser[salesCode] ?? Set(records.filter(\.isBookmarked).map(\.id))
|
||||
}
|
||||
|
||||
private func personalized(_ record: LeadDTO, salesCode: String) -> LeadDTO {
|
||||
var value = record
|
||||
value.isBookmarked = bookmarks(for: salesCode).contains(record.id)
|
||||
return value
|
||||
}
|
||||
|
||||
private func name(_ record: LeadDTO) -> String {
|
||||
record.companyName ?? [record.contactTitle, record.firstName, record.lastName]
|
||||
.compactMap { $0 }.joined(separator: " ")
|
||||
}
|
||||
}
|
||||
|
||||
enum LeadSampleData {
|
||||
static let records: [LeadDTO] = [
|
||||
make("L-240330", first: "Thomas", last: "Brenner", street: "Am Schlossberg 9",
|
||||
postal: "79111", city: "Freiburg", phone: "0160 448 2210", offers: 1, contracts: 2, day: 30),
|
||||
make("L-240326", company: "Schreinerei Vogt", street: "Talweg 22",
|
||||
postal: "79252", city: "Stegen", phone: "07661 37 12", day: 26),
|
||||
make("L-240322", company: "Hotel Schwarzwaldblick", street: "Höhenweg 1",
|
||||
postal: "79874", city: "Breitnau", phone: "07652 91 80", offers: 2, contracts: 6, day: 22),
|
||||
make("L-240318", company: "Bäckerei Hoffmann GmbH", street: "Marktstraße 14",
|
||||
postal: "79098", city: "Freiburg", phone: "0761 204 118", offers: 2, contracts: 5, day: 18, bookmarked: true),
|
||||
make("L-240309", company: "Kita Sonnenland e.V.", street: "Schulstraße 5",
|
||||
postal: "79224", city: "Umkirch", phone: "07665 50 02 1", offers: 1, contracts: 1, day: 9),
|
||||
make("L-240298", company: "Familie Yilmaz", street: "Rosenweg 17",
|
||||
postal: "79194", city: "Gundelfingen", phone: "0152 019 33 87", day: 8),
|
||||
make("L-240291", title: "Dr.", first: "Sabine", last: "Wendt", street: "Gartenstraße 8",
|
||||
postal: "79100", city: "Freiburg", phone: "0171 552 0193", offers: 1, contracts: 3, day: 7, bookmarked: true),
|
||||
make("L-240277", company: "Autohaus Kessler KG", street: "Gewerbestraße 3",
|
||||
postal: "79199", city: "Kirchzarten", phone: "07661 90 33 0", offers: 3, contracts: 8, day: 6, bookmarked: true),
|
||||
make("L-240270", company: "Gärtnerei Weber", street: "Feldweg 4",
|
||||
postal: "79206", city: "Breisach", phone: nil, day: 5),
|
||||
make("L-240261", first: "Anna", last: "Schneider", street: "Bergstraße 6",
|
||||
postal: "79249", city: "Merzhausen", phone: "0761 555 012", offers: 1, day: 4)
|
||||
]
|
||||
|
||||
private static func make(
|
||||
_ id: String, company: String? = nil, title: String? = nil,
|
||||
first: String? = nil, last: String? = nil, street: String,
|
||||
postal: String, city: String, phone: String?, offers: Int = 0,
|
||||
contracts: Int = 0, day: Int, bookmarked: Bool = false
|
||||
) -> LeadDTO {
|
||||
LeadDTO(id: id, companyName: company, contactTitle: title, firstName: first,
|
||||
lastName: last, street: street, postalCode: postal, city: city,
|
||||
phoneNumber: phone, createdAt: Date(timeIntervalSince1970: 1_710_000_000 + Double(day) * 86_400),
|
||||
offerCount: offers, unsignedContractCount: contracts, isBookmarked: bookmarked)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user