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