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

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

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

View 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."
}
}
}
}

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

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

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

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

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