diff --git a/GasTankAR.xcodeproj/project.pbxproj b/GasTankAR.xcodeproj/project.pbxproj index 3d6b484..850c264 100644 --- a/GasTankAR.xcodeproj/project.pbxproj +++ b/GasTankAR.xcodeproj/project.pbxproj @@ -303,8 +303,8 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UIRequiredDeviceCapabilities = arkit; INFOPLIST_KEY_UIStatusBarHidden = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; IPHONEOS_DEPLOYMENT_TARGET = 27.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -348,8 +348,8 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UIRequiredDeviceCapabilities = arkit; INFOPLIST_KEY_UIStatusBarHidden = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; IPHONEOS_DEPLOYMENT_TARGET = 27.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -506,7 +506,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 37Z4ZA4QU2; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 26.5; + IPHONEOS_DEPLOYMENT_TARGET = 27.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = dev.dennisnemec.GasTankARTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -528,7 +528,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 37Z4ZA4QU2; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 26.5; + IPHONEOS_DEPLOYMENT_TARGET = 27.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = dev.dennisnemec.GasTankARTests; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/GasTankAR/Assets.xcassets/LoginLogo.imageset/Contents.json b/GasTankAR/Assets.xcassets/LoginLogo.imageset/Contents.json new file mode 100644 index 0000000..d1b6b8b --- /dev/null +++ b/GasTankAR/Assets.xcassets/LoginLogo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "filename": "LoginLogo.png", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/GasTankAR/Assets.xcassets/LoginLogo.imageset/LoginLogo.png b/GasTankAR/Assets.xcassets/LoginLogo.imageset/LoginLogo.png new file mode 100644 index 0000000..8cecee1 Binary files /dev/null and b/GasTankAR/Assets.xcassets/LoginLogo.imageset/LoginLogo.png differ diff --git a/GasTankAR/ContentView.swift b/GasTankAR/ContentView.swift index 56ecf5b..7300c94 100644 --- a/GasTankAR/ContentView.swift +++ b/GasTankAR/ContentView.swift @@ -1,23 +1,33 @@ -// -// ContentView.swift -// GasTankAR -// -// Created by Dennis Nemec on 19.08.26. -// - import SwiftUI -import SwiftData struct ContentView: View { + // Composition root: the server stubs are replaced here when APIs are available. + private let loginUseCase: any LoginUseCase = DefaultLoginUseCase( + authenticationService: StubAuthenticationService() + ) + @State private var leadService = StubLeadRemoteService(listDelay: .milliseconds(800)) + @State private var detailRepository = DefaultLeadDetailRepository(remoteService: StubLeadDetailRemoteService()) + @State private var session: LoginSession? + var body: some View { - ProjectListView() - .modelContainer(for: Project.self) + if let session { + NavigationStack { + LeadOverviewView( + session: session, + repository: DefaultLeadRepository(remoteService: leadService), + detailRepository: detailRepository + ) { + self.session = nil + } + } + } else { + LoginView(loginUseCase: loginUseCase) { + session = $0 + } + } } } -#if DEBUG #Preview { - ProjectListView() - .modelContainer(PreviewSupport.container) + ContentView() } -#endif diff --git a/GasTankAR/Data/Leads/DefaultLeadDetailRepository.swift b/GasTankAR/Data/Leads/DefaultLeadDetailRepository.swift new file mode 100644 index 0000000..3b8b0c4 --- /dev/null +++ b/GasTankAR/Data/Leads/DefaultLeadDetailRepository.swift @@ -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] = [:] + + 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 + } + } +} diff --git a/GasTankAR/Data/Leads/DefaultLeadRepository.swift b/GasTankAR/Data/Leads/DefaultLeadRepository.swift new file mode 100644 index 0000000..496b0c6 --- /dev/null +++ b/GasTankAR/Data/Leads/DefaultLeadRepository.swift @@ -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 + } +} diff --git a/GasTankAR/Data/Leads/LeadARPhotoStore.swift b/GasTankAR/Data/Leads/LeadARPhotoStore.swift new file mode 100644 index 0000000..bc07da6 --- /dev/null +++ b/GasTankAR/Data/Leads/LeadARPhotoStore.swift @@ -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( + predicate: #Predicate { $0.leadID == leadID })) + let legacy = try context.fetch(FetchDescriptor( + 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() + return projects.flatMap(\.photos) + .filter { seen.insert($0.persistentModelID).inserted } + .sorted { $0.creationDate > $1.creationDate } + } +} diff --git a/GasTankAR/Data/Leads/LeadARProjectsService.swift b/GasTankAR/Data/Leads/LeadARProjectsService.swift new file mode 100644 index 0000000..d50edd5 --- /dev/null +++ b/GasTankAR/Data/Leads/LeadARProjectsService.swift @@ -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( + predicate: #Predicate { $0.leadID == leadID })) + let legacy = try context.fetch(FetchDescriptor( + 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( + 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( + 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( + 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." + } + } + } +} diff --git a/GasTankAR/Data/Leads/LeadDetailRemoteService.swift b/GasTankAR/Data/Leads/LeadDetailRemoteService.swift new file mode 100644 index 0000000..204b3d2 --- /dev/null +++ b/GasTankAR/Data/Leads/LeadDetailRemoteService.swift @@ -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 + } +} diff --git a/GasTankAR/Data/Leads/LeadDetailSampleData.swift b/GasTankAR/Data/Leads/LeadDetailSampleData.swift new file mode 100644 index 0000000..46a80ff --- /dev/null +++ b/GasTankAR/Data/Leads/LeadDetailSampleData.swift @@ -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.. [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 + ) + } +} diff --git a/GasTankAR/Data/Leads/StubLeadDetailRemoteService.swift b/GasTankAR/Data/Leads/StubLeadDetailRemoteService.swift new file mode 100644 index 0000000..e0af625 --- /dev/null +++ b/GasTankAR/Data/Leads/StubLeadDetailRemoteService.swift @@ -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] + ) + } + } +} diff --git a/GasTankAR/Data/Leads/StubLeadRemoteService.swift b/GasTankAR/Data/Leads/StubLeadRemoteService.swift new file mode 100644 index 0000000..aefdf99 --- /dev/null +++ b/GasTankAR/Data/Leads/StubLeadRemoteService.swift @@ -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] = [:] + + 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 { + 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) + } +} diff --git a/GasTankAR/Domain/AR/ARProject.swift b/GasTankAR/Domain/AR/ARProject.swift new file mode 100644 index 0000000..91a0754 --- /dev/null +++ b/GasTankAR/Domain/AR/ARProject.swift @@ -0,0 +1,32 @@ +import Foundation + +/// Neutral projection for project selection. Persistence and customer associations +/// belong to the host application's service. +struct ARProject: Identifiable, Equatable { + let id: UUID + let name: String + let tank: Tank + let createdAt: Date + let source: Source + let reference: String? + + enum Source { case supplied, custom } +} + +@MainActor +protocol ARProjectsProviding { + func updateProject(id: UUID, name: String, tank: Tank) throws -> ARProject + func deleteProject(id: UUID) throws + func loadProjects() throws -> [ARProject] + func createProject(name: String, tank: Tank) throws -> ARProject +} + +nonisolated extension TankSize { + var volumeTitle: String { + switch self { + case .t1_2: "2.700 l" + case .t2_1: "4.850 l" + case .t2_9: "6.400 l" + } + } +} diff --git a/GasTankAR/Domain/Leads/Lead.swift b/GasTankAR/Domain/Leads/Lead.swift new file mode 100644 index 0000000..e918a90 --- /dev/null +++ b/GasTankAR/Domain/Leads/Lead.swift @@ -0,0 +1,36 @@ +import Foundation + +struct Lead: Identifiable, Equatable, Sendable { + let id: String + let companyName: String? + let contact: LeadContact? + let address: LeadAddress + let phoneNumber: String? + let createdAt: Date + let offerCount: Int + let unsignedContractCount: Int + var isBookmarked: Bool +} + +struct LeadContact: Equatable, Sendable { + let title: String? + let firstName: String + let lastName: String +} + +struct LeadAddress: Equatable, Sendable { + let street: String? + let postalCode: String + let city: String +} + +enum LeadError: Error, Equatable, Sendable { + case offline + case timeout + case serviceUnavailable + case unauthorized + case forbidden + case notFound(leadID: String) + case invalidResponse + case unexpected +} diff --git a/GasTankAR/Domain/Leads/LeadDetail.swift b/GasTankAR/Domain/Leads/LeadDetail.swift new file mode 100644 index 0000000..2cd6f0d --- /dev/null +++ b/GasTankAR/Domain/Leads/LeadDetail.swift @@ -0,0 +1,77 @@ +import Foundation + +struct LeadDetail: Equatable, Sendable { + let lead: Lead + let customerType: String + let contactName: String? + let contactRole: String? + let mobile: String? + let email: String? + let tankOptions: [LeadTankOption] + var offers: [LeadOffer] + let photos: [LeadPhoto] +} + +struct LeadTankOption: Identifiable, Equatable, Sendable { + let id: String + let title: String + let tank: Tank +} + +struct LeadOffer: Identifiable, Equatable, Sendable { + let id: String + let title: String + let kind: LeadOfferKind + let amount: Decimal + let billing: LeadBilling + let validUntil: Date? + let acceptedAt: Date? + var groups: [LeadDocumentGroup] + var tank: Tank? = nil + + var documents: [LeadDocument] { groups.flatMap(\.documents) } + var signedCount: Int { documents.filter { $0.status == .signed }.count } + var isComplete: Bool { !documents.isEmpty && signedCount == documents.count } +} + +enum LeadOfferKind: String, Sendable { case purchase, rental, service } +enum LeadBilling: String, Sendable { case once, monthly, annually } + +struct LeadDocumentGroup: Identifiable, Equatable, Sendable { + let id: String + let title: String + var documents: [LeadDocument] + var signedCount: Int { documents.filter { $0.status == .signed }.count } + var isComplete: Bool { !documents.isEmpty && signedCount == documents.count } +} + +struct LeadDocument: Identifiable, Equatable, Sendable { + let id: String + let title: String + let pageCount: Int + var status: LeadSignatureStatus + let file: LeadFileReference + var signaturePlacement: PDFSignaturePlacement? = nil +} + +enum LeadSignatureStatus: String, Sendable { case pending, signed } + +struct LeadPhoto: Identifiable, Equatable, Sendable { + let id: String + let title: String + let isAR: Bool + let thumbnail: LeadFileReference + let original: LeadFileReference +} + +struct LeadFileReference: Hashable, Sendable { + let id: String + let version: String + let kind: LeadFileKind +} + +enum LeadFileKind: String, Sendable { case pdf, image } + +enum LeadFileError: Error, Equatable { + case notFound, invalidFile, storageUnavailable +} diff --git a/GasTankAR/Domain/Leads/LeadDetailRepository.swift b/GasTankAR/Domain/Leads/LeadDetailRepository.swift new file mode 100644 index 0000000..15aa4a6 --- /dev/null +++ b/GasTankAR/Domain/Leads/LeadDetailRepository.swift @@ -0,0 +1,34 @@ +import Foundation + +@MainActor +protocol LeadDetailRepository { + func detail(id: String, salesCode: String) async throws -> LeadDetail + func file(_ reference: LeadFileReference, leadID: String, salesCode: String) async throws -> URL +} + +struct GetLeadDetailUseCase { + let repository: any LeadDetailRepository + + func execute(id: String, salesCode: String) async throws -> LeadDetail { + try await repository.detail(id: id, salesCode: salesCode) + } +} + +struct DownloadLeadDocumentUseCase { + let repository: any LeadDetailRepository + + func execute(_ document: LeadDocument, leadID: String, salesCode: String) async throws -> URL { + guard document.file.kind == .pdf else { throw LeadFileError.invalidFile } + return try await repository.file(document.file, leadID: leadID, salesCode: salesCode) + } +} + +struct LoadLeadPhotoUseCase { + let repository: any LeadDetailRepository + + func execute(_ photo: LeadPhoto, original: Bool = false, + leadID: String, salesCode: String) async throws -> URL { + try await repository.file(original ? photo.original : photo.thumbnail, + leadID: leadID, salesCode: salesCode) + } +} diff --git a/GasTankAR/Domain/Leads/LeadQuery.swift b/GasTankAR/Domain/Leads/LeadQuery.swift new file mode 100644 index 0000000..21214af --- /dev/null +++ b/GasTankAR/Domain/Leads/LeadQuery.swift @@ -0,0 +1,23 @@ +import Foundation + +struct LeadQuery: Equatable, Sendable { + var searchText = "" + var sort: LeadSort = .newest + var filter: LeadFilter = .all + + var normalized: LeadQuery { + var query = self + query.searchText = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + return query + } +} + +enum LeadSort: Sendable { + case newest + case nameAscending +} + +enum LeadFilter: Sendable { + case all + case unsignedContracts +} diff --git a/GasTankAR/Domain/Leads/LeadRepository.swift b/GasTankAR/Domain/Leads/LeadRepository.swift new file mode 100644 index 0000000..7ece47f --- /dev/null +++ b/GasTankAR/Domain/Leads/LeadRepository.swift @@ -0,0 +1,8 @@ +import Foundation + +@MainActor +protocol LeadRepository { + func leads(for salesCode: String, query: LeadQuery) async throws -> [Lead] + func lead(id: String, salesCode: String) async throws -> Lead + func setBookmark(_ bookmarked: Bool, leadID: String, salesCode: String) async throws -> Lead +} diff --git a/GasTankAR/Domain/Leads/LeadUseCases.swift b/GasTankAR/Domain/Leads/LeadUseCases.swift new file mode 100644 index 0000000..fdbed42 --- /dev/null +++ b/GasTankAR/Domain/Leads/LeadUseCases.swift @@ -0,0 +1,33 @@ +import Foundation + +struct LoadLeadsUseCase { + let repository: any LeadRepository + + func execute(salesCode: String) async throws -> [Lead] { + try await repository.leads(for: salesCode, query: LeadQuery()) + } +} + +struct SearchLeadsUseCase { + let repository: any LeadRepository + + func execute(salesCode: String, query: LeadQuery) async throws -> [Lead] { + try await repository.leads(for: salesCode, query: query.normalized) + } +} + +struct SetLeadBookmarkUseCase { + let repository: any LeadRepository + + func execute(_ bookmarked: Bool, leadID: String, salesCode: String) async throws -> Lead { + try await repository.setBookmark(bookmarked, leadID: leadID, salesCode: salesCode) + } +} + +struct GetLeadUseCase { + let repository: any LeadRepository + + func execute(id: String, salesCode: String) async throws -> Lead { + try await repository.lead(id: id, salesCode: salesCode) + } +} diff --git a/GasTankAR/Model/LeadARProjectLink.swift b/GasTankAR/Model/LeadARProjectLink.swift new file mode 100644 index 0000000..f819550 --- /dev/null +++ b/GasTankAR/Model/LeadARProjectLink.swift @@ -0,0 +1,16 @@ +import Foundation +import SwiftData + +/// Integration-owned association; AR views never receive this model. +@Model +final class LeadARProjectLink { + var leadID: String + var offerID: String? + @Relationship(deleteRule: .nullify) var project: Project? + + init(leadID: String, offerID: String? = nil, project: Project) { + self.leadID = leadID + self.offerID = offerID + self.project = project + } +} diff --git a/GasTankAR/Model/Project.swift b/GasTankAR/Model/Project.swift index 201a717..bd39874 100644 --- a/GasTankAR/Model/Project.swift +++ b/GasTankAR/Model/Project.swift @@ -11,6 +11,9 @@ import ARKit @Model final class Project { + var projectID: UUID = UUID() + var projectName: String = "" + /// Identifier given by the user for the project. In DOCUframe terms /// it is the LeadID. var leadID: String diff --git a/GasTankAR/Services/AuthenticationService.swift b/GasTankAR/Services/AuthenticationService.swift new file mode 100644 index 0000000..c80a35b --- /dev/null +++ b/GasTankAR/Services/AuthenticationService.swift @@ -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 +} diff --git a/GasTankAR/Services/CustomerLocationService.swift b/GasTankAR/Services/CustomerLocationService.swift new file mode 100644 index 0000000..a12613c --- /dev/null +++ b/GasTankAR/Services/CustomerLocationService.swift @@ -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 + } +} diff --git a/GasTankAR/Services/LoginUseCase.swift b/GasTankAR/Services/LoginUseCase.swift new file mode 100644 index 0000000..a420f54 --- /dev/null +++ b/GasTankAR/Services/LoginUseCase.swift @@ -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) + ) + } +} diff --git a/GasTankAR/Services/Signing/DefaultPDFSigningService.swift b/GasTankAR/Services/Signing/DefaultPDFSigningService.swift new file mode 100644 index 0000000..182cc44 --- /dev/null +++ b/GasTankAR/Services/Signing/DefaultPDFSigningService.swift @@ -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 + } + } +} diff --git a/GasTankAR/Services/Signing/LeadSignedDocumentStore.swift b/GasTankAR/Services/Signing/LeadSignedDocumentStore.swift new file mode 100644 index 0000000..82a92de --- /dev/null +++ b/GasTankAR/Services/Signing/LeadSignedDocumentStore.swift @@ -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 + } +} diff --git a/GasTankAR/Services/Signing/LeadSignedPDFUploadService.swift b/GasTankAR/Services/Signing/LeadSignedPDFUploadService.swift new file mode 100644 index 0000000..576ee35 --- /dev/null +++ b/GasTankAR/Services/Signing/LeadSignedPDFUploadService.swift @@ -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 + } +} diff --git a/GasTankAR/Services/Signing/PDFSigningService.swift b/GasTankAR/Services/Signing/PDFSigningService.swift new file mode 100644 index 0000000..4cf654b --- /dev/null +++ b/GasTankAR/Services/Signing/PDFSigningService.swift @@ -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." + } + } +} diff --git a/GasTankAR/Services/StubAuthenticationService.swift b/GasTankAR/Services/StubAuthenticationService.swift new file mode 100644 index 0000000..64f45ca --- /dev/null +++ b/GasTankAR/Services/StubAuthenticationService.swift @@ -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") + } +} diff --git a/GasTankAR/Services/TankThumbnailRenderer.swift b/GasTankAR/Services/TankThumbnailRenderer.swift new file mode 100644 index 0000000..990204a --- /dev/null +++ b/GasTankAR/Services/TankThumbnailRenderer.swift @@ -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() + + 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 + } +} diff --git a/GasTankAR/Views/AR/Projects/ARProjectCreateView.swift b/GasTankAR/Views/AR/Projects/ARProjectCreateView.swift new file mode 100644 index 0000000..59b4307 --- /dev/null +++ b/GasTankAR/Views/AR/Projects/ARProjectCreateView.swift @@ -0,0 +1,140 @@ +import SwiftUI + +struct ARProjectCreateView: View { + let isEditing: Bool + let onCreate: (String, Tank, Bool) -> Void + @State private var name = "" + @State private var tank = Tank() + @Environment(\.tankCatalog) private var catalog + + init(project: ARProject? = nil, onCreate: @escaping (String, Tank, Bool) -> Void) { + isEditing = project != nil + self.onCreate = onCreate + _name = State(initialValue: project?.name ?? "") + _tank = State(initialValue: project?.tank ?? Tank()) + } + + private var canCreate: Bool { + !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && catalog.definition(for: tank) != nil + } + + var body: some View { + Form { + Section { + VStack(spacing: 8) { + ARProjectTankImage(tank: tank) + .frame(height: 150) + .frame(maxWidth: .infinity) + Text("\(tank.size.volumeTitle) · \(tank.placement.displayName) · \(catalog.displayName(for: tank.color))") + .font(.subheadline).foregroundStyle(.secondary) + } + .padding(.vertical, 8) + } + Section("Name") { + TextField("Projektname", text: $name) + .textInputAutocapitalization(.sentences) + } + Section("Tankkonfiguration") { + ARProjectConfigurationView(tank: $tank) + } + } + .navigationTitle(isEditing ? "Projekt bearbeiten" : "Neues Projekt") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(isEditing ? "Speichern" : "Anlegen") { onCreate(name, tank, false) } + .disabled(!canCreate) + } + } + .safeAreaInset(edge: .bottom) { + Button { onCreate(name, tank, true) } label: { + Label(isEditing ? "Speichern und AR-Modus starten" : "Anlegen und AR-Modus starten", systemImage: "arkit") + .font(.headline).frame(maxWidth: .infinity).padding(.vertical, 10) + } + .buttonStyle(.borderedProminent) + .disabled(!canCreate) + .padding() + .background(.bar) + } + .onAppear { tank = catalog.normalized(tank) } + } +} + +struct ARProjectConfigurationView: View { + @Binding var tank: Tank + @Environment(\.tankCatalog) private var catalog + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Volumen").font(.subheadline) + Picker("Volumen", selection: $tank.size) { + ForEach(catalog.sizes(for: tank.placement)) { size in + Text(size.volumeTitle).tag(size) + } + } + .pickerStyle(.segmented) + } + .padding(.vertical, 4) + + VStack(alignment: .leading, spacing: 10) { + Text("Aufstellung").font(.subheadline) + Picker("Aufstellung", selection: $tank.placement) { + ForEach(catalog.placements) { placement in + Text(placement.displayName).tag(placement) + } + } + .pickerStyle(.segmented) + } + .padding(.vertical, 4) + + HStack { + Text("Farbe") + Spacer() + ForEach(catalog.colors(for: tank.size, placement: tank.placement)) { color in + Button { tank.color = color } label: { + Circle().fill(swatch(color)) + .frame(width: 28, height: 28) + .overlay { Circle().strokeBorder(.gray.opacity(0.5), lineWidth: 1) } + .padding(4) + .overlay { Circle().strokeBorder(tank.color == color ? Color.accentColor : .clear, lineWidth: 2) } + .frame(minWidth: 44, minHeight: 44) + } + .buttonStyle(.plain) + .accessibilityLabel(catalog.displayName(for: color)) + .accessibilityAddTraits(tank.color == color ? .isSelected : []) + } + Text(catalog.displayName(for: tank.color)).foregroundStyle(.secondary).font(.subheadline) + } + .onChange(of: tank.placement) { _, _ in normalize() } + .onChange(of: tank.size) { _, _ in normalize() } + } + + private func normalize() { + tank = catalog.normalized(tank) + tank.showLidOnly = false + } + + private func swatch(_ color: TankColor) -> Color { + switch color { + case .white: .white + case .green: Color(red: 0.49, green: 0.65, blue: 0.42) + case .olive: Color(red: 0.37, green: 0.40, blue: 0.22) + } + } +} + +#Preview("Projekt konfigurieren") { + NavigationStack { + ARProjectCreateView { _, _, _ in } + } + .frame(width: 600, height: 760) +} + +#Preview("Projekt konfigurieren – Dunkel") { + NavigationStack { + ARProjectCreateView { _, _, _ in } + } + .frame(width: 600, height: 760) + .preferredColorScheme(.dark) +} diff --git a/GasTankAR/Views/AR/Projects/ARProjectTankImage.swift b/GasTankAR/Views/AR/Projects/ARProjectTankImage.swift new file mode 100644 index 0000000..d28a986 --- /dev/null +++ b/GasTankAR/Views/AR/Projects/ARProjectTankImage.swift @@ -0,0 +1,31 @@ +import SwiftUI + +struct ARProjectTankImage: View { + let tank: Tank + @Environment(\.tankCatalog) private var catalog + @State private var image: UIImage? + @State private var failed = false + + var body: some View { + Group { + if let image { + Image(uiImage: image).resizable().scaledToFit() + } else if failed { + Image(systemName: "cube.transparent").foregroundStyle(.secondary) + .accessibilityLabel("Tankvorschau nicht verfügbar") + } else { + ProgressView().controlSize(.small) + } + } + .task(id: tank) { + image = nil + failed = false + await Task.yield() + guard !Task.isCancelled else { return } + if let definition = catalog.definition(for: tank) { + image = TankThumbnailRenderer.shared.image(for: definition) + } + failed = image == nil + } + } +} diff --git a/GasTankAR/Views/AR/Projects/ARProjectsSheet.swift b/GasTankAR/Views/AR/Projects/ARProjectsSheet.swift new file mode 100644 index 0000000..e3b8ddf --- /dev/null +++ b/GasTankAR/Views/AR/Projects/ARProjectsSheet.swift @@ -0,0 +1,202 @@ +import SwiftUI + +struct ARProjectsSheet: View { + let onOpen: (UUID) -> Void + @State private var model: ARProjectsViewModel + @State private var creating = false + @State private var editing: ARProject? + @Environment(\.dismiss) private var dismiss + + init(service: any ARProjectsProviding, onOpen: @escaping (UUID) -> Void) { + self.onOpen = onOpen + _model = State(initialValue: ARProjectsViewModel(service: service)) + } + + var body: some View { + NavigationStack { + ARProjectListView(supplied: model.supplied, + custom: model.custom, onOpen: onOpen, onDelete: model.delete, onEdit: { editing = $0 }, onCreate: { creating = true }) + .overlay { if model.isLoading { ProgressView() } } + .navigationTitle("AR-Projekte") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Abbrechen", systemImage: "xmark") { dismiss() } + .labelStyle(.iconOnly) + } + ToolbarItem(placement: .primaryAction) { + Button("Neues Projekt", systemImage: "plus") { creating = true } + .labelStyle(.iconOnly) + .buttonStyle(.borderedProminent) + } + } + .navigationDestination(isPresented: $creating) { + ARProjectCreateView { name, tank, start in + guard let project = model.create(name: name, tank: tank) else { return } + creating = false + if start { onOpen(project.id) } + } + } + .navigationDestination(isPresented: Binding( + get: { editing != nil }, set: { if !$0 { editing = nil } } + )) { + if let project = editing { + ARProjectCreateView(project: project) { name, tank, start in + guard let updated = model.update(id: project.id, name: name, tank: tank) else { return } + editing = nil + if start { onOpen(updated.id) } + } + .id(project.id) + } + } + } + .task { model.load() } + .alert("Projektaktion fehlgeschlagen", + isPresented: Binding(get: { model.errorMessage != nil }, + set: { if !$0 { model.errorMessage = nil } })) { + Button("OK", role: .cancel) { model.errorMessage = nil } + } message: { Text(model.errorMessage ?? "") } + } +} + +struct ARProjectListView: View { + let supplied: [ARProject] + let custom: [ARProject] + let onOpen: (UUID) -> Void + let onDelete: (UUID) -> Void + let onEdit: (ARProject) -> Void + let onCreate: () -> Void + + var body: some View { + List { + if !supplied.isEmpty { + Section { + ForEach(supplied) { project in + HStack(spacing: 12) { + ARProjectRow(project: project) + Button { onOpen(project.id) } label: { + Label("AR", systemImage: "arkit") + } + .buttonStyle(.bordered) + .accessibilityLabel("AR-Modus starten: " + project.name) + } + + } + } header: { Text("Bereitgestellte Projekte") } + footer: { Text("Diese Projekte werden automatisch anhand der Angebote bereitgestellt.") } + } + + Section("Eigene Projekte") { + ForEach(custom) { project in + HStack(spacing: 12) { + Button { onEdit(project) } label: { + ARProjectRow(project: project).contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityHint("Projektkonfiguration bearbeiten") + Button { onOpen(project.id) } label: { + Label("AR", systemImage: "arkit") + } + .buttonStyle(.bordered) + .accessibilityLabel("AR-Modus starten: " + project.name) + Divider() + .frame(height: 24) + .accessibilityHidden(true) + Button { onEdit(project) } label: { + Image(systemName: "chevron.right") + .font(.body.weight(.semibold)) + .foregroundStyle(.secondary) + .frame(minWidth: 44, minHeight: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Projekt bearbeiten: " + project.name) + } + .swipeActions(edge: .trailing) { + Button("Löschen", systemImage: "trash", role: .destructive) { + onDelete(project.id) + } + .tint(.red) + } + } + Button(action: onCreate) { + Label("Neues Projekt", systemImage: "plus.circle.fill") + .padding(.vertical, 8) + } + } + } + .listStyle(.insetGrouped) + } +} + +struct ARProjectRow: View { + let project: ARProject + @Environment(\.tankCatalog) private var catalog + @Environment(\.dynamicTypeSize) private var typeSize + + var body: some View { + HStack(spacing: 12) { + ARProjectTankImage(tank: project.tank) + .frame(width: 76, height: 60) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 5) { + Text(project.name).font(.headline) + if typeSize.isAccessibilitySize { + configuration + reference + } else { + HStack(alignment: .firstTextBaseline) { + configuration + Spacer(minLength: 4) + reference + } + } + } + Spacer(minLength: 0) + } + .padding(.vertical, 6) + .accessibilityElement(children: .combine) + } + + private var configuration: some View { + Text("\(project.tank.size.volumeTitle) · \(project.tank.placement.displayName) · \(catalog.displayName(for: project.tank.color))") + .font(.caption).foregroundStyle(.secondary) + } + + @ViewBuilder + private var reference: some View { + if let reference = project.reference { + Text(reference).font(.caption2).foregroundStyle(.secondary) + } + } + +} + +#Preview("AR-Projekte") { + ARProjectsSheet(service: ARProjectsPreviewService()) { _ in } + .frame(maxWidth: 600, maxHeight: 700) +} + +private struct ARProjectsPreviewService: ARProjectsProviding { + func updateProject(id: UUID, name: String, tank: Tank) throws -> ARProject { + ARProject(id: id, name: name, tank: tank, createdAt: .now, source: .custom, reference: nil) + } + + func deleteProject(id: UUID) throws {} + + func loadProjects() throws -> [ARProject] { + [ + ARProject(id: UUID(), name: "Flüssiggastank 2.700 l oberirdisch", tank: Tank(), + createdAt: .now, source: .supplied, reference: "A-2026-1182"), + ARProject(id: UUID(), name: "Flüssiggastank 4.850 l unterirdisch", + tank: Tank(size: .t2_1, color: .olive, placement: .belowGround), + createdAt: .now, source: .supplied, reference: "A-2026-1197"), + ARProject(id: UUID(), name: "Variante hinter dem Haus", tank: Tank(color: .green), + createdAt: .now, source: .custom, reference: nil) + ] + } + + func createProject(name: String, tank: Tank) throws -> ARProject { + ARProject(id: UUID(), name: name, tank: tank, createdAt: .now, source: .custom, reference: nil) + } +} diff --git a/GasTankAR/Views/AR/Projects/ARProjectsViewModel.swift b/GasTankAR/Views/AR/Projects/ARProjectsViewModel.swift new file mode 100644 index 0000000..d1f4e16 --- /dev/null +++ b/GasTankAR/Views/AR/Projects/ARProjectsViewModel.swift @@ -0,0 +1,55 @@ +import Foundation +import Observation + +@MainActor @Observable +final class ARProjectsViewModel { + private(set) var supplied: [ARProject] = [] + private(set) var custom: [ARProject] = [] + private(set) var isLoading = true + var errorMessage: String? + private let service: any ARProjectsProviding + + init(service: any ARProjectsProviding) { + self.service = service + } + + func load() { + isLoading = true + defer { isLoading = false } + do { + let projects = try service.loadProjects() + supplied = projects.filter { $0.source == .supplied } + custom = projects.filter { $0.source == .custom } + } catch { errorMessage = error.localizedDescription } + } + + func update(id: UUID, name: String, tank: Tank) -> ARProject? { + do { + let project = try service.updateProject(id: id, name: name, tank: tank) + if let index = custom.firstIndex(where: { $0.id == id }) { custom[index] = project } + return project + } catch { + errorMessage = error.localizedDescription + return nil + } + } + + func delete(id: UUID) { + do { + try service.deleteProject(id: id) + supplied.removeAll { $0.id == id } + custom.removeAll { $0.id == id } + } catch { errorMessage = error.localizedDescription } + } + + func create(name: String, tank: Tank) -> ARProject? { + do { + let project = try service.createProject(name: name, tank: tank) + custom.insert(project, at: 0) + return project + } catch { + errorMessage = error.localizedDescription + return nil + } + } +} diff --git a/GasTankAR/Views/Docuframe/DocuframeViewModel.swift b/GasTankAR/Views/Docuframe/DocuframeViewModel.swift index 291fb3d..9dcb0e1 100644 --- a/GasTankAR/Views/Docuframe/DocuframeViewModel.swift +++ b/GasTankAR/Views/Docuframe/DocuframeViewModel.swift @@ -63,6 +63,7 @@ final class DocuframeViewModel { successText: String, action: ([DocuframePhoto], String) async throws -> Void ) async { + guard !isProcessing else { return } let selected = photos.filter { selectedIDs.contains($0.persistentModelID) } guard !selected.isEmpty else { result = .failure(message: DocuframeError.noPhotosSelected.localizedDescription) @@ -85,7 +86,7 @@ final class DocuframeViewModel { /// Bildet das Persistenz-Model auf ein persistenzunabhängiges Domänen-DTO ab. private static func makeDTO(_ photo: ProjectPhoto) -> DocuframePhoto { DocuframePhoto( - imageData: photo.imageData, + imageData: photo.annotatedImageData ?? photo.imageData, creationDate: photo.creationDate, latitude: photo.latitude, longitude: photo.longitude diff --git a/GasTankAR/Views/GalleryView.swift b/GasTankAR/Views/GalleryView.swift index 4fc1939..a4616bb 100644 --- a/GasTankAR/Views/GalleryView.swift +++ b/GasTankAR/Views/GalleryView.swift @@ -60,8 +60,8 @@ struct GalleryView: View { PhotoViewer( photo: photo, photos: project.photos.sorted { $0.creationDate > $1.creationDate } - ) { - delete(photo) + ) { selected in + delete(selected) } } } @@ -158,9 +158,10 @@ private struct DrawingStroke { var isEraser: Bool } -private struct PhotoViewer: View { +struct PhotoViewer: View { let photos: [ProjectPhoto] - let onDelete: () -> Void + let onDelete: (ProjectPhoto) throws -> Void + let usesNavigationStack: Bool @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var modelContext @@ -172,19 +173,36 @@ private struct PhotoViewer: View { @State private var drawingColor = Color.red @State private var drawingWidth: Double = 0.012 @State private var imageRevision = 0 + @State private var deletionError: String? private var currentPhoto: ProjectPhoto { photos[currentIndex] } - init(photo: ProjectPhoto, photos: [ProjectPhoto], onDelete: @escaping () -> Void) { + init(photo: ProjectPhoto, photos: [ProjectPhoto], usesNavigationStack: Bool = true, + onDelete: @escaping (ProjectPhoto) throws -> Void) { self.photos = photos self.onDelete = onDelete + self.usesNavigationStack = usesNavigationStack _currentIndex = State(initialValue: photos.firstIndex(of: photo) ?? 0) } var body: some View { - NavigationStack { + Group { + if usesNavigationStack { + NavigationStack { content } + } else { + content + } + } + .alert("Foto konnte nicht gelöscht werden", isPresented: Binding( + get: { deletionError != nil }, set: { if !$0 { deletionError = nil } } + )) { + Button("OK", role: .cancel) { deletionError = nil } + } message: { Text(deletionError ?? "") } + } + + private var content: some View { TabView(selection: $currentIndex) { ForEach(photos.indices, id: \.self) { index in photoLayout(for: photos[index]) @@ -197,7 +215,7 @@ private struct PhotoViewer: View { ToolbarItem(placement: .topBarLeading) { if isDrawing { EmptyView() - } else { + } else if usesNavigationStack { Button("Fertig") { dismiss() } } } @@ -213,8 +231,10 @@ private struct PhotoViewer: View { .accessibilityLabel("Zeichnung speichern") } else { Button("Löschen", systemImage: "trash", role: .destructive) { - onDelete() - dismiss() + do { + try onDelete(currentPhoto) + dismiss() + } catch { deletionError = error.localizedDescription } } } } @@ -250,7 +270,6 @@ private struct PhotoViewer: View { } } .navigationBarTitleDisplayMode(.inline) - } } private func photoLayout(for photo: ProjectPhoto) -> some View { @@ -619,6 +638,6 @@ private struct PhotoLocationCard: View { #Preview("Foto-Detail") { let photo = PreviewSupport.samplePhoto() - PhotoViewer(photo: photo, photos: [photo]) {} + PhotoViewer(photo: photo, photos: [photo]) { _ in } } #endif diff --git a/GasTankAR/Views/Leads/Detail/LeadARProjectsSheet.swift b/GasTankAR/Views/Leads/Detail/LeadARProjectsSheet.swift new file mode 100644 index 0000000..882c5e6 --- /dev/null +++ b/GasTankAR/Views/Leads/Detail/LeadARProjectsSheet.swift @@ -0,0 +1,47 @@ +import SwiftUI +import SwiftData + +/// Composition boundary: the AR picker receives only neutral project data. +struct LeadARProjectsSheet: View { + let detail: LeadDetail + @Environment(\.modelContext) private var context + @State private var service: LeadARProjectsService? + + var body: some View { + Group { + if let service { + LeadARProjectsContent(service: service) + } else { + ProgressView() + } + } + .task { + if service == nil { + service = LeadARProjectsService(detail: detail, container: context.container) + } + } + } +} + +private struct LeadARProjectsContent: View { + let service: LeadARProjectsService + @State private var activeProject: Project? + @State private var errorMessage: String? + + var body: some View { + ARProjectsSheet(service: service) { id in + do { activeProject = try service.resolveProject(id: id) } + catch { errorMessage = error.localizedDescription } + } + .fullScreenCover(item: $activeProject) { project in + if let context = project.modelContext { + ARExperienceView(project: project).modelContext(context) + } + } + .alert("AR-Projekt konnte nicht geöffnet werden", + isPresented: Binding(get: { errorMessage != nil }, + set: { if !$0 { errorMessage = nil } })) { + Button("OK", role: .cancel) { errorMessage = nil } + } message: { Text(errorMessage ?? "") } + } +} diff --git a/GasTankAR/Views/Leads/Detail/LeadContactSection.swift b/GasTankAR/Views/Leads/Detail/LeadContactSection.swift new file mode 100644 index 0000000..bc6c6fd --- /dev/null +++ b/GasTankAR/Views/Leads/Detail/LeadContactSection.swift @@ -0,0 +1,57 @@ +import SwiftUI + +struct LeadContactSection: View { + let detail: LeadDetail + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + LeadSectionHeading(title: "KONTAKT") + VStack(spacing: 0) { + LeadContactRow(title: "Ansprechpartner", value: detail.contactDescription, + icon: "person.fill", color: .gray) + Divider().padding(.leading, 56) + LeadContactRow(title: "Telefon", value: detail.lead.phoneNumber, + icon: "phone.fill", color: .green) + Divider().padding(.leading, 56) + LeadContactRow(title: "Mobil", value: detail.mobile, + icon: "iphone", color: .green) + Divider().padding(.leading, 56) + LeadContactRow(title: "E-Mail", value: detail.email, + icon: "envelope.fill", color: .blue) + Divider().padding(.leading, 56) + LeadContactRow(title: "Anschrift", + value: [detail.lead.address.street, detail.lead.locality].compactMap { $0 }.joined(separator: "\n"), + icon: "mappin", color: .red) + } + .background(LeadDetailStyle.card, in: .rect(cornerRadius: 12)) + } + } +} + +private struct LeadContactRow: View { + let title: LocalizedStringKey + let value: String? + let icon: String + let color: Color + @ScaledMetric(relativeTo: .body) private var iconSize: CGFloat = 30 + + var body: some View { + HStack(spacing: 12) { + Image(systemName: icon) + .font(.body.weight(.semibold)) + .foregroundStyle(.white) + .frame(width: iconSize, height: iconSize) + .background(color, in: .rect(cornerRadius: 7)) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 3) { + Text(title).font(.caption).foregroundStyle(.secondary) + Text(value ?? "Nicht hinterlegt") + .font(.body) + .foregroundStyle(value == nil ? .secondary : .primary) + .textSelection(.enabled) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 16).padding(.vertical, 11) + } +} diff --git a/GasTankAR/Views/Leads/Detail/LeadDetailHeaderView.swift b/GasTankAR/Views/Leads/Detail/LeadDetailHeaderView.swift new file mode 100644 index 0000000..947773a --- /dev/null +++ b/GasTankAR/Views/Leads/Detail/LeadDetailHeaderView.swift @@ -0,0 +1,74 @@ +import SwiftUI + +struct LeadDetailHeaderView: View { + let detail: LeadDetail + let canStartAR: Bool + let onStartAR: () -> Void + @Environment(\.openURL) private var openURL + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @State private var actionUnavailable = false + + var body: some View { + let layout = dynamicTypeSize.isAccessibilitySize + ? AnyLayout(VStackLayout(alignment: .leading, spacing: 16)) + : AnyLayout(HStackLayout(spacing: 20)) + layout { + HStack(spacing: 18) { + Text(detail.initials) + .font(.title2.weight(.medium)) + .frame(width: 64, height: 64) + .background(.gray.gradient, in: .circle) + .foregroundStyle(.white) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 6) { + Text(detail.lead.displayName).font(.title.bold()) + Text("\(detail.lead.id) · \(detail.customerType) · \(detail.lead.address.city)") + .font(.subheadline).foregroundStyle(.secondary) + } + } + Spacer(minLength: 0) + HStack(spacing: 24) { + HStack(spacing: 8) { + action("Anrufen", icon: "phone.fill", url: detail.phoneURL) + action("E-Mail", icon: "envelope.fill", url: detail.emailURL) + action("Route", icon: "location.fill", url: detail.routeURL) + } + Button(action: onStartAR) { + VStack(spacing: 8) { + Image(systemName: "arkit").font(.title3) + Text("AR").font(.caption) + } + .frame(minWidth: 60, minHeight: 56) + .padding(6) + .foregroundStyle(.white) + .background(LeadDetailStyle.blue, in: .rect(cornerRadius: 14)) + } + .buttonStyle(.plain) + .opacity(canStartAR ? 1 : 0.5) + .disabled(!canStartAR) + .accessibilityLabel("AR-Modus starten") + } + } + .alert("Aktion nicht verfügbar", isPresented: $actionUnavailable) { + Button("OK", role: .cancel) {} + } message: { + Text("Auf diesem Gerät ist keine passende App für diese Aktion verfügbar.") + } + } + + private func action(_ title: LocalizedStringKey, icon: String, url: URL?) -> some View { + Button { + guard let url else { return } + openURL(url) { accepted in actionUnavailable = !accepted } + } label: { + VStack(spacing: 8) { + Image(systemName: icon).font(.title3) + Text(title).font(.caption) + } + .frame(minWidth: 60, minHeight: 56) + .padding(6) + .background(LeadDetailStyle.card, in: .rect(cornerRadius: 14)) + } + .disabled(url == nil) + } +} diff --git a/GasTankAR/Views/Leads/Detail/LeadDetailPresentation.swift b/GasTankAR/Views/Leads/Detail/LeadDetailPresentation.swift new file mode 100644 index 0000000..8204277 --- /dev/null +++ b/GasTankAR/Views/Leads/Detail/LeadDetailPresentation.swift @@ -0,0 +1,110 @@ +import SwiftUI + +enum LeadDetailStyle { + static let blue = LeadOverviewStyle.blue + static let background = Color(uiColor: .systemGroupedBackground) + static let card = Color(uiColor: .secondarySystemGroupedBackground) +} + +extension LeadDetail { + var initials: String { + String(lead.displayName.split(separator: " ").prefix(2).compactMap(\.first)).uppercased() + } + + var contactDescription: String? { + let value = [contactName, contactRole].compactMap { $0 }.joined(separator: " · ") + return value.isEmpty ? nil : value + } + + var phoneURL: URL? { + guard let phone = lead.phoneNumber else { return nil } + let digits = phone.filter { $0.isNumber || $0 == "+" } + return digits.isEmpty ? nil : URL(string: "tel:\(digits)") + } + + var emailURL: URL? { + guard let email, !email.isEmpty else { return nil } + var url = URLComponents() + url.scheme = "mailto" + url.path = email + return url.url + } + + var routeURL: URL? { + var url = URLComponents() + url.scheme = "https" + url.host = "maps.apple.com" + url.queryItems = [URLQueryItem(name: "daddr", value: lead.fullAddress)] + return url.url + } +} + +extension LeadOffer { + var subtitle: String { + let kindName: String + switch kind { + case .purchase: kindName = "Kauf" + case .rental: kindName = "Miete" + case .service: kindName = "Service" + } + let price = amount.formatted(.currency(code: "EUR").locale(Locale(identifier: "de_DE"))) + let interval: String + switch billing { + case .once: interval = "netto" + case .monthly: interval = "/ Monat" + case .annually: interval = "/ Jahr" + } + let date = acceptedAt ?? validUntil + let suffix = date.map { + (acceptedAt == nil ? "gültig bis " : "angenommen am ") + + $0.formatted(.dateTime.day().month().year().locale(Locale(identifier: "de_DE"))) + } + return [id, kindName, "\(price) \(interval)", suffix].compactMap { $0 }.joined(separator: " · ") + } +} + +struct LeadSectionHeading: View { + let title: LocalizedStringKey + var body: some View { + Text(title).font(.caption).foregroundStyle(.secondary) + .padding(.horizontal, 16) + } +} + +struct LeadSignatureProgress: View { + let signed: Int + let total: Int + var body: some View { + if total > 0 && signed == total { + Label("Vollständig", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green).font(.subheadline) + } else { + Text("\(signed) von \(total)").foregroundStyle(.secondary).font(.subheadline) + } + } +} + +struct LeadSignButton: View { + let action: () -> Void + + var body: some View { + Button(action: action) { + Label("Unterschreiben", systemImage: "signature") + .font(.subheadline.weight(.medium)) + } + .buttonStyle(.borderedProminent) + .buttonBorderShape(.capsule) + } +} + +extension Error { + var leadFileMessage: LocalizedStringResource { + if let error = self as? LeadError { return error.message } + switch self as? LeadFileError { + case .notFound: return "Diese Datei ist nicht mehr verfügbar." + case .invalidFile: return "Die heruntergeladene Datei ist ungültig und kann nicht geöffnet werden." + case .storageUnavailable: return "Die Datei konnte nicht auf diesem Gerät gespeichert werden." + case nil: return "Die Datei konnte nicht geladen werden. Bitte versuche es erneut." + } + } +} diff --git a/GasTankAR/Views/Leads/Detail/LeadDetailScreen.swift b/GasTankAR/Views/Leads/Detail/LeadDetailScreen.swift new file mode 100644 index 0000000..7345c14 --- /dev/null +++ b/GasTankAR/Views/Leads/Detail/LeadDetailScreen.swift @@ -0,0 +1,98 @@ +import SwiftUI +import SwiftData + +struct LeadDetailScreen: View { + @State private var model: LeadDetailViewModel + @State private var retryID = 0 + @Environment(\.dismiss) private var dismiss + private let leadID: String + private let salesCode: String + private let repository: any LeadDetailRepository + let onLogout: () -> Void + + init(leadID: String, salesCode: String, repository: any LeadDetailRepository, + onLogout: @escaping () -> Void) { + self.leadID = leadID + self.salesCode = salesCode + self.repository = repository + _model = State(initialValue: LeadDetailViewModel( + leadID: leadID, salesCode: salesCode, repository: repository + )) + self.onLogout = onLogout + } + + var body: some View { + @Bindable var model = model + ZStack { + if let error = model.error { + ContentUnavailableView { + Label("Interessent konnte nicht geladen werden", systemImage: "exclamationmark.triangle") + } description: { + Text(error.message) + } actions: { + if error == .unauthorized { + Button("Erneut anmelden", action: onLogout) + } else if error.allowsRetry { + Button("Erneut versuchen") { retryID += 1 } + } + Button("Zur Übersicht") { dismiss() } + } + } else if let detail = model.detail { + LeadDetailView(detail: detail, model: model, salesCode: salesCode, + repository: repository, onLogout: onLogout) + } else { + LeadDetailSkeletonView() + } + } + .navigationTitle("") + .navigationBarTitleDisplayMode(.inline) + .tint(LeadDetailStyle.blue) + .toolbar { + ToolbarItem(placement: .principal) { + HStack(spacing: 8) { + Image("LoginLogo") + .resizable() + .scaledToFit() + .frame(width: 30, height: 30) + .clipShape(.rect(cornerRadius: 8)) + .accessibilityHidden(true) + Text("DrachenGas") + .font(.headline) + .foregroundStyle(LeadDetailStyle.blue) + } + .accessibilityElement(children: .combine) + } + if model.detail == nil && model.error == nil { + ToolbarItem(placement: .topBarTrailing) { + ProgressView().accessibilityLabel("Interessent wird geladen") + } + } + } + .sheet(item: $model.selectedDocument) { document in + LeadPDFPreview(document: document, leadID: leadID, salesCode: salesCode, + repository: repository, onLogout: onLogout, + signedURL: model.signedDocumentURLs[document.id]) + } + .sheet(item: $model.signingRequest) { request in + LeadSigningSheet(request: request, leadID: leadID, salesCode: salesCode, + repository: repository, onSigned: model.didSign) + } + .modelContainer(for: [Project.self, LeadARProjectLink.self]) + .task(id: retryID) { await model.load() } + } +} + +#Preview("Interessent", traits: .fixedLayout(width: 1194, height: 834)) { + NavigationStack { + LeadDetailScreen(leadID: "L-240318", salesCode: "MKR", + repository: DefaultLeadDetailRepository( + remoteService: StubLeadDetailRemoteService(delay: .zero)), + onLogout: {}) + } +} + +#Preview("Ladezustand", traits: .fixedLayout(width: 1194, height: 834)) { + NavigationStack { + LeadDetailSkeletonView().navigationTitle("") + } +} diff --git a/GasTankAR/Views/Leads/Detail/LeadDetailSkeletonView.swift b/GasTankAR/Views/Leads/Detail/LeadDetailSkeletonView.swift new file mode 100644 index 0000000..b3ccc39 --- /dev/null +++ b/GasTankAR/Views/Leads/Detail/LeadDetailSkeletonView.swift @@ -0,0 +1,53 @@ +import SwiftUI + +struct LeadDetailSkeletonView: View { + var body: some View { + LeadDetailPageLayout { + HStack(spacing: 18) { + Circle().fill(.quaternary).frame(width: 64, height: 64) + VStack(alignment: .leading, spacing: 10) { + Text("Name des Interessenten").font(.title.bold()) + Text("Lead-ID · Kundentyp · Ort").font(.subheadline) + }.redacted(reason: .placeholder) + Spacer() + } + } left: { + VStack(alignment: .leading, spacing: 24) { + LeadSectionHeading(title: "KONTAKT") + VStack(alignment: .leading, spacing: 22) { + ForEach(["Ansprechpartner", "Telefon", "Mobil", "E-Mail", "Anschrift"], id: \.self) { label in + HStack(spacing: 12) { + RoundedRectangle(cornerRadius: 6).fill(.quaternary).frame(width: 30, height: 30) + VStack(alignment: .leading, spacing: 4) { + Text(label).font(.caption) + Text("Daten werden geladen").font(.body).redacted(reason: .placeholder) + } + } + } + } + .padding(16).frame(maxWidth: .infinity, alignment: .leading) + .background(LeadDetailStyle.card, in: .rect(cornerRadius: 12)) + LeadSectionHeading(title: "STANDORT") + RoundedRectangle(cornerRadius: 12).fill(.quaternary).frame(height: LeadLocationSection.mapHeight) + } + } right: { + VStack(alignment: .leading, spacing: 16) { + LeadSectionHeading(title: "ANGEBOTE") + ForEach(0..<3) { _ in + VStack(alignment: .leading, spacing: 12) { + Text("Angebot für einen Flüssiggastank").font(.headline) + Text("Angebotsnummer · Preis · Gültigkeit").font(.subheadline) + } + .redacted(reason: .placeholder) + .padding(20).frame(maxWidth: .infinity, alignment: .leading) + .background(LeadDetailStyle.card, in: .rect(cornerRadius: 12)) + } + LeadSectionHeading(title: "FOTOGALERIE") + RoundedRectangle(cornerRadius: 12).fill(.quaternary).frame(height: 120) + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel("Interessent wird geladen") + .allowsHitTesting(false) + } +} diff --git a/GasTankAR/Views/Leads/Detail/LeadDetailView.swift b/GasTankAR/Views/Leads/Detail/LeadDetailView.swift new file mode 100644 index 0000000..a5187e6 --- /dev/null +++ b/GasTankAR/Views/Leads/Detail/LeadDetailView.swift @@ -0,0 +1,68 @@ +import SwiftUI +import SwiftData + +struct LeadDetailView: View { + let detail: LeadDetail + let model: LeadDetailViewModel + let salesCode: String + let repository: any LeadDetailRepository + let onLogout: () -> Void + @State private var showsARProjects = false + @State private var galleryRevision = 0 + + var body: some View { + LeadDetailPageLayout { + LeadDetailHeaderView(detail: detail, canStartAR: true, onStartAR: { showsARProjects = true }) + } left: { + VStack(spacing: 28) { + LeadContactSection(detail: detail) + LeadLocationSection(name: detail.lead.displayName, address: detail.lead.address) + } + } right: { + VStack(spacing: 28) { + LeadOffersSection(offers: detail.offers, model: model) + LeadPhotoGallerySection(photos: detail.photos, leadID: detail.lead.id, + salesCode: salesCode, repository: repository, onLogout: onLogout, + refreshID: galleryRevision) + } + } + .sheet(isPresented: $showsARProjects, onDismiss: { galleryRevision += 1 }) { + LeadARProjectsSheet(detail: detail) + } + } +} + +struct LeadDetailPageLayout: View { + let header: Header + let left: Left + let right: Right + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + + init(@ViewBuilder header: () -> Header, @ViewBuilder left: () -> Left, + @ViewBuilder right: () -> Right) { + self.header = header() + self.left = left() + self.right = right() + } + + var body: some View { + GeometryReader { geometry in + ScrollView { + VStack(alignment: .leading, spacing: 28) { + header + if geometry.size.width >= 900 && !dynamicTypeSize.isAccessibilitySize { + HStack(alignment: .top, spacing: 24) { + left.frame(width: (geometry.size.width - 72) * 0.34) + right.frame(maxWidth: .infinity) + } + } else { + left + right + } + } + .padding(24) + } + .background(LeadDetailStyle.background.ignoresSafeArea()) + } + } +} diff --git a/GasTankAR/Views/Leads/Detail/LeadDetailViewModel.swift b/GasTankAR/Views/Leads/Detail/LeadDetailViewModel.swift new file mode 100644 index 0000000..edd2d0e --- /dev/null +++ b/GasTankAR/Views/Leads/Detail/LeadDetailViewModel.swift @@ -0,0 +1,106 @@ +import Foundation +import Observation + +@MainActor +@Observable +final class LeadDetailViewModel { + private(set) var detail: LeadDetail? + private(set) var error: LeadError? + var expandedOffers: Set = [] + var expandedGroups: Set = [] + var selectedDocument: LeadDocument? + var signingRequest: LeadSigningRequest? + private(set) var signedDocumentURLs: [String: URL] = [:] + private let leadID: String + private let salesCode: String + private let getDetail: GetLeadDetailUseCase + private var generation = 0 + + init(leadID: String, salesCode: String, repository: any LeadDetailRepository) { + self.leadID = leadID + self.salesCode = salesCode + self.getDetail = GetLeadDetailUseCase(repository: repository) + } + + var lead: Lead? { detail?.lead } + var isAllExpanded: Bool { + guard let detail, !detail.offers.isEmpty else { return false } + return Set(detail.offers.map(\.id)).isSubset(of: expandedOffers) + && Set(detail.offers.flatMap(\.groups).map(\.id)).isSubset(of: expandedGroups) + } + + func load() async { + generation += 1 + let request = generation + detail = nil + error = nil + do { + let result = try await getDetail.execute(id: leadID, salesCode: salesCode) + try Task.checkCancellation() + guard request == generation else { return } + detail = result + let documents = result.offers.flatMap(\.documents) + signedDocumentURLs = LeadSignedDocumentStore().savedURLs( + documents: documents, leadID: leadID, salesCode: salesCode + ) + for document in documents { + if let url = signedDocumentURLs[document.id] { didSign(document, url: url) } + } + if let first = result.offers.first { + expandedOffers = [first.id] + expandedGroups = Set(first.groups.filter { !$0.isComplete }.map(\.id)) + } else { + expandedOffers = [] + expandedGroups = [] + } + } catch is CancellationError { + } catch { + guard request == generation, !Task.isCancelled else { return } + self.error = error as? LeadError ?? .unexpected + } + } + + func startSigning(offer: LeadOffer, group: LeadDocumentGroup? = nil) { + let documents = group?.documents ?? offer.documents + guard documents.contains(where: { $0.status == .pending }) else { return } + signingRequest = LeadSigningRequest( + offerID: offer.id, + signerName: detail?.contactName ?? detail?.lead.displayName ?? "Kunde", + documents: documents + ) + } + + func didSign(_ document: LeadDocument, url: URL) { + signedDocumentURLs[document.id] = url + guard var detail else { return } + for offerIndex in detail.offers.indices { + for groupIndex in detail.offers[offerIndex].groups.indices { + for documentIndex in detail.offers[offerIndex].groups[groupIndex].documents.indices { + if detail.offers[offerIndex].groups[groupIndex].documents[documentIndex].id == document.id { + detail.offers[offerIndex].groups[groupIndex].documents[documentIndex].status = .signed + } + } + } + } + self.detail = detail + } + + func toggleOffer(_ id: String) { + if !expandedOffers.insert(id).inserted { expandedOffers.remove(id) } + } + + func toggleGroup(_ id: String) { + if !expandedGroups.insert(id).inserted { expandedGroups.remove(id) } + } + + func toggleAll() { + guard let detail else { return } + if isAllExpanded { + expandedOffers = [] + expandedGroups = [] + } else { + expandedOffers = Set(detail.offers.map(\.id)) + expandedGroups = Set(detail.offers.flatMap(\.groups).map(\.id)) + } + } +} diff --git a/GasTankAR/Views/Leads/Detail/LeadFileViewModel.swift b/GasTankAR/Views/Leads/Detail/LeadFileViewModel.swift new file mode 100644 index 0000000..80a5f29 --- /dev/null +++ b/GasTankAR/Views/Leads/Detail/LeadFileViewModel.swift @@ -0,0 +1,33 @@ +import Foundation +import Observation + +@MainActor +@Observable +final class LeadFileViewModel { + private(set) var url: URL? + private(set) var message: LocalizedStringResource? + private(set) var needsLogin = false + private(set) var allowsRetry = true + private var generation = 0 + + func load(using download: () async throws -> URL) async { + generation += 1 + let request = generation + url = nil + message = nil + needsLogin = false + allowsRetry = true + do { + let result = try await download() + try Task.checkCancellation() + guard request == generation else { return } + url = result + } catch is CancellationError { + } catch { + guard request == generation, !Task.isCancelled else { return } + message = error.leadFileMessage + needsLogin = error as? LeadError == .unauthorized + allowsRetry = (error as? LeadError)?.allowsRetry ?? true + } + } +} diff --git a/GasTankAR/Views/Leads/Detail/LeadLocationSection.swift b/GasTankAR/Views/Leads/Detail/LeadLocationSection.swift new file mode 100644 index 0000000..36dbb87 --- /dev/null +++ b/GasTankAR/Views/Leads/Detail/LeadLocationSection.swift @@ -0,0 +1,91 @@ +import SwiftUI +import MapKit + +struct LeadLocationSection: View { + let name: String + let address: LeadAddress + var locationService: any CustomerLocationService = AppleMapsCustomerLocationService() + @State private var coordinate: CLLocationCoordinate2D? + @State private var position: MapCameraPosition = .automatic + @State private var errorMessage: LocalizedStringResource? + @State private var retryID = 0 + + static let mapHeight: CGFloat = 328 + + private var addressText: String { + [address.street, "\(address.postalCode) \(address.city)"] + .compactMap { $0 } + .joined(separator: ", ") + } + + private var mapsURL: URL? { + var components = URLComponents(string: "https://maps.apple.com/") + components?.queryItems = [URLQueryItem(name: "q", value: addressText)] + return components?.url + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + LeadSectionHeading(title: "STANDORT") + ZStack { + LeadDetailStyle.card + if let coordinate { + Map(position: $position) { + Marker(name, coordinate: coordinate) + .tint(LeadDetailStyle.blue) + } + .mapStyle(.standard(pointsOfInterest: .excludingAll)) + .accessibilityLabel("Standort von \(name), \(addressText)") + } else if let errorMessage { + ContentUnavailableView { + Label("Standort nicht verfügbar", systemImage: "mappin.slash") + } description: { + Text(errorMessage) + } actions: { + Button("Erneut versuchen") { retryID += 1 } + } + } else { + ProgressView("Standort wird geladen") + } + } + .safeAreaInset(edge: .bottom, spacing: 0) { + if let mapsURL { + Link(destination: mapsURL) { + Label("In Apple Maps öffnen", systemImage: "map") + .font(.subheadline.weight(.semibold)) + .frame(maxWidth: .infinity, minHeight: 48) + .foregroundStyle(.white) + .background(LeadDetailStyle.blue) + } + .buttonStyle(.plain) + } + } + .frame(maxWidth: .infinity) + .frame(height: Self.mapHeight) + .clipShape(.rect(cornerRadius: 12)) + } + .task(id: addressText + "#\(retryID)") { + coordinate = nil + errorMessage = nil + do { + let result = try await locationService.coordinate(for: addressText) + try Task.checkCancellation() + position = .region(MKCoordinateRegion( + center: result, latitudinalMeters: 800, longitudinalMeters: 800 + )) + coordinate = result + } catch is CancellationError { + } catch { + guard !Task.isCancelled else { return } + if error is CustomerLocationError { + errorMessage = "Für diese Anschrift wurde kein Standort gefunden." + } else if let networkError = error as? URLError, + [.notConnectedToInternet, .networkConnectionLost, .timedOut].contains(networkError.code) { + errorMessage = "Keine Verbindung zu Apple Maps. Bitte prüfe deine Internetverbindung und versuche es erneut." + } else { + errorMessage = "Apple Maps konnte den Standort gerade nicht ermitteln. Bitte versuche es erneut." + } + } + } + } +} diff --git a/GasTankAR/Views/Leads/Detail/LeadOffersSection.swift b/GasTankAR/Views/Leads/Detail/LeadOffersSection.swift new file mode 100644 index 0000000..297a0f3 --- /dev/null +++ b/GasTankAR/Views/Leads/Detail/LeadOffersSection.swift @@ -0,0 +1,192 @@ +import SwiftUI + +struct LeadOffersSection: View { + let offers: [LeadOffer] + @Environment(\.accessibilityReduceMotion) private var reduceMotion + let model: LeadDetailViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + LeadSectionHeading(title: "ANGEBOTE") + Spacer() + Button(model.isAllExpanded ? "Alle zuklappen" : "Alle aufklappen") { + withAnimation(reduceMotion ? nil : .smooth(duration: 0.32)) { + model.toggleAll() + } + } + .font(.subheadline) + .disabled(offers.isEmpty) + .padding(.trailing, 16) + } + if offers.isEmpty { + ContentUnavailableView("Noch keine Angebote", systemImage: "doc.text", + description: Text("Für diesen Interessenten liegen noch keine Angebote vor.")) + .frame(maxWidth: .infinity) + .background(LeadDetailStyle.card, in: .rect(cornerRadius: 12)) + } else { + VStack(spacing: 0) { + ForEach(offers) { offer in + LeadOfferRow(offer: offer, model: model) + if offer.id != offers.last?.id { Divider().padding(.leading, 44) } + } + } + .background(LeadDetailStyle.card, in: .rect(cornerRadius: 12)) + } + } + } +} + +struct LeadOfferRow: View { + let offer: LeadOffer + let model: LeadDetailViewModel + + var body: some View { + VStack(spacing: 0) { + LeadTreeHeading(title: offer.title, subtitle: offer.subtitle, icon: "doc.on.doc.fill", + expanded: model.expandedOffers.contains(offer.id), + signed: offer.signedCount, total: offer.documents.count, prominent: true, + onSign: { model.startSigning(offer: offer) }) { + model.toggleOffer(offer.id) + } + LeadTreeDisclosure(expanded: model.expandedOffers.contains(offer.id)) { + ForEach(offer.groups) { group in + Divider().padding(.leading, 44) + LeadDocumentGroupRow(group: group, offer: offer, model: model).padding(.leading, 24) + } + } + } + } +} + +struct LeadDocumentGroupRow: View { + let group: LeadDocumentGroup + let offer: LeadOffer + let model: LeadDetailViewModel + + var body: some View { + VStack(spacing: 0) { + LeadTreeHeading(title: group.title, + subtitle: "\(group.documents.count) \(group.documents.count == 1 ? "PDF" : "PDFs")", + icon: "folder.fill", expanded: model.expandedGroups.contains(group.id), + signed: group.signedCount, total: group.documents.count, prominent: false, + onSign: { model.startSigning(offer: offer, group: group) }) { + model.toggleGroup(group.id) + } + LeadTreeDisclosure(expanded: model.expandedGroups.contains(group.id)) { + ForEach(group.documents) { document in + Divider().padding(.leading, 70) + LeadDocumentRow(document: document) { model.selectedDocument = document } + .padding(.leading, 50) + } + } + } + } +} + +private struct LeadTreeDisclosure: View { + let expanded: Bool + @ViewBuilder let content: Content + + var body: some View { + // Keep the natural content height while animating the clipped container. + VStack(spacing: 0) { + content + } + .fixedSize(horizontal: false, vertical: true) + .frame(height: expanded ? nil : 0, alignment: .top) + .clipped() + .allowsHitTesting(expanded) + .accessibilityHidden(!expanded) + } +} + +private struct LeadTreeHeading: View { + let title: String + let subtitle: String + let icon: String + let expanded: Bool + let signed: Int + let total: Int + let prominent: Bool + let onSign: () -> Void + let onToggle: () -> Void + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + + var body: some View { + let layout = dynamicTypeSize.isAccessibilitySize + ? AnyLayout(VStackLayout(alignment: .leading, spacing: 10)) + : AnyLayout(HStackLayout(spacing: 12)) + layout { + Button { + withAnimation(reduceMotion ? nil : .smooth(duration: 0.32)) { + onToggle() + } + } label: { + HStack(spacing: 14) { + Image(systemName: "chevron.right") + .rotationEffect(.degrees(expanded ? 90 : 0)) + .font(.caption.weight(.semibold)).frame(width: 12) + .foregroundStyle(LeadDetailStyle.blue) + Image(systemName: icon).font(.title3).frame(width: 22) + .foregroundStyle(LeadDetailStyle.blue) + VStack(alignment: .leading, spacing: 4) { + Text(title).font(prominent ? .headline : .body) + .foregroundStyle(.primary).fixedSize(horizontal: false, vertical: true) + Text(subtitle).font(.caption).foregroundStyle(.secondary) + .lineLimit(2) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .contentShape(.rect) + .frame(minHeight: 44) + } + .buttonStyle(.plain) + .accessibilityLabel(title) + .accessibilityValue(expanded ? "Aufgeklappt" : "Zugeklappt") + .accessibilityHint("Zum Auf- oder Zuklappen doppeltippen") + LeadSignatureProgress(signed: signed, total: total) + .fixedSize(horizontal: !dynamicTypeSize.isAccessibilitySize, vertical: true) + if signed < total { LeadSignButton(action: onSign) } + } + .padding(.horizontal, 16).padding(.vertical, 10) + } +} + +struct LeadDocumentRow: View { + let document: LeadDocument + let onOpen: () -> Void + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + + var body: some View { + Button(action: onOpen) { + HStack(spacing: 14) { + Image(systemName: "doc.fill").foregroundStyle(.red) + .font(.title3).frame(width: 22) + VStack(alignment: .leading, spacing: 4) { + Text(document.title).foregroundStyle(.primary) + .fixedSize(horizontal: false, vertical: true) + Text("PDF · \(document.pageCount) \(document.pageCount == 1 ? "Seite" : "Seiten")") + .font(.caption).foregroundStyle(.secondary) + if dynamicTypeSize.isAccessibilitySize { status } + } + Spacer(minLength: 4) + if !dynamicTypeSize.isAccessibilitySize { status } + Image(systemName: "chevron.right").font(.caption).foregroundStyle(.tertiary) + } + .padding(.horizontal, 16).padding(.vertical, 11) + .frame(minHeight: 48) + .contentShape(.rect) + } + .buttonStyle(.plain) + .accessibilityHint("PDF öffnen") + } + + private var status: some View { + Label(document.status == .signed ? "Unterschrieben" : "Offen", + systemImage: document.status == .signed ? "checkmark.circle.fill" : "circle.fill") + .font(.caption).foregroundStyle(document.status == .signed ? .green : .orange) + .fixedSize() + } +} diff --git a/GasTankAR/Views/Leads/Detail/LeadPDFPreview.swift b/GasTankAR/Views/Leads/Detail/LeadPDFPreview.swift new file mode 100644 index 0000000..ae21c8f --- /dev/null +++ b/GasTankAR/Views/Leads/Detail/LeadPDFPreview.swift @@ -0,0 +1,62 @@ +import SwiftUI +import PDFKit + +struct LeadPDFPreview: View { + let document: LeadDocument + let leadID: String + let salesCode: String + let repository: any LeadDetailRepository + let onLogout: () -> Void + var signedURL: URL? = nil + @State private var model = LeadFileViewModel() + @State private var retryID = 0 + @State private var showsPreview = false + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + ZStack { + if let url = signedURL ?? model.url { + LeadPDFView(url: url) + .fullScreenCover(isPresented: $showsPreview) { + LeadDocumentPreview(url: url) + } + } else if let message = model.message { + ContentUnavailableView { + Label("PDF konnte nicht geöffnet werden", systemImage: "doc.badge.ellipsis") + } description: { Text(message) } actions: { + if model.needsLogin { + Button("Erneut anmelden", action: onLogout) + } else if model.allowsRetry { + Button("Erneut versuchen") { retryID += 1 } + } + } + } else { + ProgressView("PDF wird heruntergeladen …") + } + } + .navigationTitle(document.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(role: .cancel) { dismiss() } + .labelStyle(.iconOnly) + } + ToolbarItem(placement: .topBarTrailing) { + Button("In Vorschau öffnen", systemImage: "arrow.up.left.and.arrow.down.right") { + showsPreview = true + } + .labelStyle(.iconOnly) + .disabled(signedURL == nil && model.url == nil) + } + } + .task(id: retryID) { + guard signedURL == nil else { return } + await model.load { + try await DownloadLeadDocumentUseCase(repository: repository) + .execute(document, leadID: leadID, salesCode: salesCode) + } + } + } + } +} diff --git a/GasTankAR/Views/Leads/Detail/LeadPhotoGallerySection.swift b/GasTankAR/Views/Leads/Detail/LeadPhotoGallerySection.swift new file mode 100644 index 0000000..b08b49a --- /dev/null +++ b/GasTankAR/Views/Leads/Detail/LeadPhotoGallerySection.swift @@ -0,0 +1,316 @@ +import SwiftUI +import SwiftData + +struct LeadPhotoGallerySection: View { + let photos: [LeadPhoto] + let leadID: String + let salesCode: String + let repository: any LeadDetailRepository + let onLogout: () -> Void + var refreshID: Int = 0 + @Environment(\.modelContext) private var context + @State private var localPhotos: [ProjectPhoto] = [] + @State private var photoStore: LeadARPhotoStore? + @State private var localError: String? + + private var galleryPhotos: [LeadGalleryPhoto] { + localPhotos.map { .local($0) } + photos.map { .remote($0) } + } + + @State private var showsAll = false + @State private var isSelecting = false + @State private var transfer = DocuframeViewModel() + @State private var selected: LeadGalleryPhoto? + @State private var selectedInGallery: LeadGalleryPhoto? + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + LeadSectionHeading(title: "FOTOGALERIE") + Spacer() + if !galleryPhotos.isEmpty { + Button("Alle \(galleryPhotos.count) anzeigen") { showsAll = true } + .font(.subheadline).padding(.trailing, 16) + } + } + VStack(spacing: 0) { + if let localError { + Text(localError).font(.footnote).foregroundStyle(.secondary) + Button("Erneut versuchen") { loadLocalPhotos() } + } + if galleryPhotos.isEmpty { + ContentUnavailableView( + "Noch keine Fotos", + systemImage: "photo.on.rectangle", + description: Text("Fotos können über den Kamera-Button im AR-Modus aufgenommen werden.") + ) + } else { + ScrollView(.horizontal) { + HStack(spacing: 12) { + ForEach(Array(galleryPhotos.prefix(4))) { photo in + thumbnail(photo).frame(width: 180, height: 120) + } + } + } + .scrollIndicators(.hidden) + } + } + .padding(16) + .frame(maxWidth: .infinity) + .background(LeadDetailStyle.card, in: .rect(cornerRadius: 12)) + } + .task(id: refreshID) { loadLocalPhotos() } + .sheet(isPresented: $showsAll, onDismiss: { + isSelecting = false + transfer.selectedIDs.removeAll() + transfer.result = nil + if let photo = selectedInGallery { + selectedInGallery = nil + selected = photo + } + }) { + NavigationStack { + ScrollView { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 220))], spacing: 16) { + ForEach(galleryPhotos) { photo in thumbnail(photo, inGallery: true).frame(height: 160) } + } + .padding() + } + .safeAreaInset(edge: .bottom) { + if isSelecting { transferBar } + } + .interactiveDismissDisabled(transfer.isProcessing) + .alert(transfer.result?.title ?? "DOCUframe", isPresented: Binding( + get: { showsAll && transfer.result != nil }, set: { if !$0 && showsAll { transfer.result = nil } } + )) { + Button("OK", role: .cancel) { transfer.result = nil } + } message: { Text(transfer.result?.message ?? "") } + .navigationTitle("Fotogalerie") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .primaryAction) { + selectionButton + } + ToolbarItem(placement: .cancellationAction) { + Button(role: .cancel) { showsAll = false } + .labelStyle(.iconOnly) + .disabled(transfer.isProcessing) + } + } + } + } + .navigationDestination(isPresented: Binding( + get: { selected != nil }, set: { if !$0 { selected = nil } } + )) { + if let photo = selected { + photoScreen(photo) + } + } + } + + private var selectionButton: some View { + Button(isSelecting ? "Abbrechen" : "Auswählen") { + isSelecting.toggle() + transfer.selectedIDs.removeAll() + } + .disabled(localPhotos.isEmpty || transfer.isProcessing) + } + + private func isSelected(_ photo: LeadGalleryPhoto) -> Bool { + if case .local(let local) = photo { return transfer.isSelected(local) } + return false + } + + private var transferBar: some View { + VStack(spacing: 10) { + Text("AR-Aufnahmen zum Übertragen auswählen") + .font(.footnote).foregroundStyle(.secondary) + Button { + Task { await transfer.upload(from: localPhotos, leadID: leadID) } + } label: { + HStack { + if transfer.isProcessing { ProgressView() } + Label("\(transfer.selectionCount) Fotos an DOCUframe senden", systemImage: "arrow.up.doc") + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(!transfer.hasSelection || transfer.isProcessing) + Text("Demo: Der DOCUframe-Versand wird derzeit simuliert.") + .font(.caption).foregroundStyle(.secondary) + } + .padding() + .background(.bar) + } + + private func loadLocalPhotos() { + do { + let store = LeadARPhotoStore(container: context.container) + localPhotos = try store.photos(for: leadID) + photoStore = store + transfer.selectedIDs.formIntersection(Set(localPhotos.map(\.persistentModelID))) + if localPhotos.isEmpty { isSelecting = false } + localError = nil + } catch { + localError = "AR-Aufnahmen konnten nicht geladen werden." + } + } + + @ViewBuilder + private func photoImage(_ photo: LeadGalleryPhoto, original: Bool) -> some View { + switch photo { + case .remote(let remote): + LeadPhotoImage(photo: remote, original: original, leadID: leadID, + salesCode: salesCode, repository: repository, onLogout: onLogout) + case .local(let local): + if original { + if let image = UIImage(data: local.annotatedImageData ?? local.imageData) { + Image(uiImage: image).resizable().scaledToFit() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ContentUnavailableView("Foto nicht verfügbar", systemImage: "photo.badge.exclamationmark") + } + } else { + PhotoThumbnail(imageData: local.annotatedImageData ?? local.imageData) + } + } + } + + private func thumbnail(_ photo: LeadGalleryPhoto, inGallery: Bool = false) -> some View { + Button { + if inGallery && isSelecting { + if case .local(let local) = photo { transfer.toggleSelection(local) } + } else if inGallery { + selectedInGallery = photo + showsAll = false + } else { + selected = photo + } + } label: { + photoImage(photo, original: false) + .overlay(alignment: .topLeading) { + if photo.isAR { + Label("AR", systemImage: "arkit") + .font(.caption.bold()).padding(6) + .background(.thinMaterial, in: .capsule).padding(8) + } + } + .overlay(alignment: .topTrailing) { + if inGallery && isSelecting, case .local(let local) = photo { + Image(systemName: transfer.isSelected(local) ? "checkmark.circle.fill" : "circle") + .font(.title2) + .foregroundStyle(.white) + .padding(6) + .background(transfer.isSelected(local) ? Color.accentColor : .black.opacity(0.4), in: .circle) + .padding(8) + } + } + .clipShape(.rect(cornerRadius: 12)) + } + .buttonStyle(.plain) + .disabled(inGallery && isSelecting && (transfer.isProcessing || !photo.isLocal)) + .accessibilityLabel(photo.title) + .accessibilityAddTraits(inGallery && isSelecting && isSelected(photo) ? .isSelected : []) + } + + @ViewBuilder + private func photoScreen(_ photo: LeadGalleryPhoto) -> some View { + switch photo { + case .local(let local): + if let photoContext = local.modelContext { + PhotoViewer(photo: local, photos: localPhotos, usesNavigationStack: false) { current in + photoContext.delete(current) + do { try photoContext.save() } + catch { + photoContext.rollback() + throw error + } + } + .modelContext(photoContext) + .onDisappear { loadLocalPhotos() } + } + case .remote: + photoImage(photo, original: true) + .navigationTitle(photo.title) + .navigationBarTitleDisplayMode(.inline) + } + } + +} + +private enum LeadGalleryPhoto: Identifiable { + case remote(LeadPhoto) + case local(ProjectPhoto) + + enum ID: Hashable { + case remote(String) + case local(PersistentIdentifier) + } + + var id: ID { + switch self { + case .remote(let photo): .remote(photo.id) + case .local(let photo): .local(photo.persistentModelID) + } + } + + var title: String { + switch self { + case .remote(let photo): photo.title + case .local(let photo): "AR-Aufnahme · " + photo.creationDate.formatted(.dateTime.day().month().year().hour().minute()) + } + } + + var isLocal: Bool { + if case .local = self { return true } + return false + } + + var isAR: Bool { + switch self { + case .remote(let photo): photo.isAR + case .local: true + } + } +} + +private struct LeadPhotoImage: View { + let photo: LeadPhoto + let original: Bool + let leadID: String + let salesCode: String + let repository: any LeadDetailRepository + let onLogout: () -> Void + @State private var model = LeadFileViewModel() + @State private var retryID = 0 + + var body: some View { + ZStack { + LeadDetailStyle.card + if let url = model.url, let image = UIImage(contentsOfFile: url.path) { + Image(uiImage: image).resizable().scaledToFit() + } else if let message = model.message { + VStack(spacing: 8) { + Image(systemName: "photo.badge.exclamationmark") + if original { + Text(message) + if model.needsLogin { Button("Erneut anmelden", action: onLogout) } + else if model.allowsRetry { Button("Erneut versuchen") { retryID += 1 } } + } else { + Text("Foto nicht verfügbar").font(.caption) + } + } + .multilineTextAlignment(.center).padding() + } else { + ProgressView() + } + } + .task(id: retryID) { + await model.load { + try await LoadLeadPhotoUseCase(repository: repository) + .execute(photo, original: original, leadID: leadID, salesCode: salesCode) + } + } + } +} diff --git a/GasTankAR/Views/Leads/Overview/BookmarkedLeadCardView.swift b/GasTankAR/Views/Leads/Overview/BookmarkedLeadCardView.swift new file mode 100644 index 0000000..630795c --- /dev/null +++ b/GasTankAR/Views/Leads/Overview/BookmarkedLeadCardView.swift @@ -0,0 +1,50 @@ +import SwiftUI + +struct BookmarkedLeadCardView: View { + let lead: Lead + let isBusy: Bool + let onOpen: () -> Void + let onBookmark: () -> Void + + var body: some View { + ZStack(alignment: .topTrailing) { + Button(action: onOpen) { + VStack(alignment: .leading, spacing: 12) { + Text(lead.id) + .font(.caption) + .padding(.trailing, 36) + Text(lead.displayName) + .font(.headline) + .fixedSize(horizontal: false, vertical: true) + Text(lead.locality) + .font(.subheadline) + Label(lead.phoneNumber ?? " ", systemImage: "phone.fill") + .font(.subheadline) + .hidden() + .accessibilityHidden(true) + HStack(spacing: 12) { + LeadStatusView(offerCount: lead.offerCount, + unsignedContractCount: lead.unsignedContractCount, + highlighted: true) + .frame(maxWidth: .infinity, alignment: .leading) + Image(systemName: "chevron.forward") + .font(.caption.weight(.semibold)) + .foregroundStyle(.white.opacity(0.65)) + .accessibilityHidden(true) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(18) + .foregroundStyle(.white) + .background(LeadOverviewStyle.blue, in: .rect(cornerRadius: 18)) + .shadow(color: LeadOverviewStyle.blue.opacity(0.18), radius: 8, y: 4) + .contentShape(.rect) + } + .buttonStyle(.plain) + .accessibilityHint("Interessentendetails öffnen") + + LeadBookmarkButton(isBookmarked: true, isBusy: isBusy, action: onBookmark) + .padding(6) + } + } +} diff --git a/GasTankAR/Views/Leads/Overview/BookmarkedLeadsSection.swift b/GasTankAR/Views/Leads/Overview/BookmarkedLeadsSection.swift new file mode 100644 index 0000000..26b9f31 --- /dev/null +++ b/GasTankAR/Views/Leads/Overview/BookmarkedLeadsSection.swift @@ -0,0 +1,37 @@ +import SwiftUI + +struct BookmarkedLeadsSection: View { + let leads: [Lead] + let columns: [GridItem] + let flyingLeadID: String? + let cardMotion: Namespace.ID + let isBusy: Bool + let onOpen: (Lead) -> Void + let onBookmark: (Lead) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text("Gemerkt").font(.title2.bold()) + Text(leads.count, format: .number) + .foregroundStyle(.secondary) + } + if leads.isEmpty { + Text("Mit dem Lesezeichen kannst du dir Interessenten merken.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + LeadCardGrid(columnCount: columns.count) { + ForEach(leads) { lead in + BookmarkedLeadCardView(lead: lead, isBusy: isBusy, + onOpen: { onOpen(lead) }, + onBookmark: { onBookmark(lead) }) + .matchedGeometryEffect(id: "flight-\(lead.id)", in: cardMotion) + .opacity(flyingLeadID == lead.id ? 0 : 1) + .transition(.identity) + .zIndex(1) + } + } + } + } +} diff --git a/GasTankAR/Views/Leads/Overview/LeadCardFlightView.swift b/GasTankAR/Views/Leads/Overview/LeadCardFlightView.swift new file mode 100644 index 0000000..225e89f --- /dev/null +++ b/GasTankAR/Views/Leads/Overview/LeadCardFlightView.swift @@ -0,0 +1,23 @@ +import SwiftUI + +struct LeadCardFlight { + let lead: Lead +} + +/// The moving copy lives above the List so row layout and clipping cannot interrupt its path. +struct LeadCardFlightView: View { + let lead: Lead + let showsBookmark: Bool + + var body: some View { + var unbookmarkedLead = lead + unbookmarkedLead.isBookmarked = false + return ZStack(alignment: .top) { + LeadCardView(lead: unbookmarkedLead, isBusy: false, onOpen: {}, onBookmark: {}) + .opacity(showsBookmark ? 0 : 1) + BookmarkedLeadCardView(lead: lead, isBusy: false, onOpen: {}, onBookmark: {}) + .opacity(showsBookmark ? 1 : 0) + } + .shadow(color: .black.opacity(0.12), radius: 14, y: 8) + } +} diff --git a/GasTankAR/Views/Leads/Overview/LeadCardGrid.swift b/GasTankAR/Views/Leads/Overview/LeadCardGrid.swift new file mode 100644 index 0000000..1a86d2f --- /dev/null +++ b/GasTankAR/Views/Leads/Overview/LeadCardGrid.swift @@ -0,0 +1,44 @@ +import SwiftUI + +/// All card positions are calculated in the same transaction as the bookmark flight. +struct LeadCardGrid: Layout { + let columnCount: Int + var spacing: CGFloat = 16 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let width = proposal.width ?? 310 + let frames = frames(width: width, subviews: subviews) + return CGSize(width: width, height: frames.map(\.maxY).max() ?? 0) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, + subviews: Subviews, cache: inout ()) { + let frames = frames(width: bounds.width, subviews: subviews) + for (index, subview) in subviews.enumerated() { + let frame = frames[index] + subview.place(at: CGPoint(x: bounds.minX + frame.minX, y: bounds.minY + frame.minY), + anchor: .topLeading, + proposal: ProposedViewSize(width: frame.width, height: frame.height)) + } + } + + private func frames(width: CGFloat, subviews: Subviews) -> [CGRect] { + let columns = max(1, columnCount) + let cardWidth = max(0, (width - CGFloat(columns - 1) * spacing) / CGFloat(columns)) + var frames: [CGRect] = [] + var rowY: CGFloat = 0 + var rowHeight: CGFloat = 0 + for (index, subview) in subviews.enumerated() { + let column = index % columns + if column == 0 && index > 0 { + rowY += rowHeight + spacing + rowHeight = 0 + } + let size = subview.sizeThatFits(ProposedViewSize(width: cardWidth, height: nil)) + frames.append(CGRect(x: CGFloat(column) * (cardWidth + spacing), y: rowY, + width: cardWidth, height: size.height)) + rowHeight = max(rowHeight, size.height) + } + return frames + } +} diff --git a/GasTankAR/Views/Leads/Overview/LeadCardView.swift b/GasTankAR/Views/Leads/Overview/LeadCardView.swift new file mode 100644 index 0000000..0b263e9 --- /dev/null +++ b/GasTankAR/Views/Leads/Overview/LeadCardView.swift @@ -0,0 +1,75 @@ +import SwiftUI + +struct LeadCardView: View { + let lead: Lead + let isBusy: Bool + let onOpen: () -> Void + let onBookmark: () -> Void + + var body: some View { + ZStack(alignment: .topTrailing) { + Button(action: onOpen) { + VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 8) { + Text(lead.id) + .font(.caption) + .foregroundStyle(.secondary) + .padding(.trailing, 36) + Text(lead.displayName) + .font(.headline) + .foregroundStyle(.primary) + .fixedSize(horizontal: false, vertical: true) + Text(lead.fullAddress) + .font(.subheadline) + .foregroundStyle(.secondary) + Label(lead.phoneNumber ?? " ", systemImage: "phone.fill") + .font(.subheadline) + .foregroundStyle(LeadOverviewStyle.blue) + .hidden() + .accessibilityHidden(true) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(18) + Divider() + HStack(spacing: 12) { + LeadStatusView(offerCount: lead.offerCount, unsignedContractCount: lead.unsignedContractCount) + .frame(maxWidth: .infinity, alignment: .leading) + Image(systemName: "chevron.forward") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) + .accessibilityHidden(true) + } + .padding(.horizontal, 18) + .padding(.vertical, 12) + .background(Color.primary.opacity(0.025), in: .rect) + } + .background(Color(uiColor: .secondarySystemGroupedBackground), in: .rect(cornerRadius: 18)) + .clipShape(.rect(cornerRadius: 18)) + .contentShape(.rect) + } + .buttonStyle(.plain) + .accessibilityHint("Interessentendetails öffnen") + + LeadBookmarkButton(isBookmarked: lead.isBookmarked, isBusy: isBusy, action: onBookmark) + .padding(6) + } + } +} + +struct LeadBookmarkButton: View { + let isBookmarked: Bool + let isBusy: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: isBookmarked ? "bookmark.fill" : "bookmark") + .foregroundStyle(isBookmarked ? Color.red : Color.secondary) + .frame(width: 44, height: 44) + .contentShape(.rect) + } + .buttonStyle(.plain) + .disabled(isBusy) + .accessibilityLabel(isBookmarked ? "Nicht mehr merken" : "Interessent merken") + } +} diff --git a/GasTankAR/Views/Leads/Overview/LeadOverviewHeaderView.swift b/GasTankAR/Views/Leads/Overview/LeadOverviewHeaderView.swift new file mode 100644 index 0000000..835acbe --- /dev/null +++ b/GasTankAR/Views/Leads/Overview/LeadOverviewHeaderView.swift @@ -0,0 +1,62 @@ +import SwiftUI + +struct LeadOverviewHeaderView: View { + let session: LoginSession + let lastUpdated: Date? + let isLoading: Bool + let onRefresh: () -> Void + let onLogout: () -> Void + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + + var body: some View { + let layout = dynamicTypeSize.isAccessibilitySize + ? AnyLayout(VStackLayout(alignment: .leading, spacing: 12)) + : AnyLayout(HStackLayout(spacing: 20)) + layout { + HStack(spacing: 8) { + Image("LoginLogo") + .resizable() + .scaledToFit() + .frame(width: 34, height: 34) + .clipShape(.rect(cornerRadius: 8)) + .accessibilityHidden(true) + Text("DrachenGas") + .font(.title3.bold()) + .foregroundStyle(LeadOverviewStyle.blue) + } + Button(action: onRefresh) { + HStack(spacing: 6) { + Image(systemName: "arrow.clockwise") + if let lastUpdated { + Text("Aktualisiert um \(lastUpdated.formatted(.dateTime.hour().minute().second()))") + } else { + Text("Aktualisieren") + } + } + .font(.subheadline) + .foregroundStyle(.secondary) + } + .disabled(isLoading) + if !dynamicTypeSize.isAccessibilitySize { + Spacer(minLength: 8) + } + Menu { + Button("Abmelden", systemImage: "rectangle.portrait.and.arrow.right", action: onLogout) + } label: { + HStack(spacing: 10) { + if let displayName = session.displayName { + Text(displayName) + } + Text(session.salesCode) + .font(.subheadline.bold()) + .padding(10) + .background(LeadOverviewStyle.blue, in: .capsule) + .foregroundStyle(.white) + } + .foregroundStyle(LeadOverviewStyle.blue) + } + .accessibilityLabel("Benutzerkonto \(session.displayName ?? session.salesCode)") + } + .buttonStyle(.plain) + } +} diff --git a/GasTankAR/Views/Leads/Overview/LeadOverviewStateView.swift b/GasTankAR/Views/Leads/Overview/LeadOverviewStateView.swift new file mode 100644 index 0000000..15717a5 --- /dev/null +++ b/GasTankAR/Views/Leads/Overview/LeadOverviewStateView.swift @@ -0,0 +1,69 @@ +import SwiftUI + +struct LeadOverviewStateView: View { + let error: LeadError? + let isLoading: Bool + let isSearching: Bool + let onRetry: () -> Void + let onReset: () -> Void + let onLogin: () -> Void + + var body: some View { + VStack(spacing: 16) { + if isLoading { + ProgressView("Interessenten werden geladen …") + .padding(40) + } else if let error { + ContentUnavailableView { + Label("Interessenten nicht verfügbar", systemImage: "exclamationmark.icloud") + } description: { + Text(error.message) + } actions: { + if error == .unauthorized { + Button("Erneut anmelden", action: onLogin) + } else if error.allowsRetry { + Button("Erneut versuchen", action: onRetry) + } + if isSearching { + Button("Suche zurücksetzen", action: onReset) + } + } + } else { + ContentUnavailableView { + Label(isSearching ? "Keine Interessenten gefunden" : "Noch keine Interessenten", + systemImage: isSearching ? "magnifyingglass" : "person.2") + } description: { + Text(isSearching ? "Passe deine Suche oder den Filter an." : "Dir sind aktuell keine Interessenten zugeordnet.") + } actions: { + if isSearching { Button("Suche zurücksetzen", action: onReset) } + } + } + } + .frame(maxWidth: .infinity) + } +} + +struct LeadErrorBanner: View { + let error: LeadError + let onRetry: () -> Void + let onLogin: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Label { + Text(error.message) + } icon: { + Image(systemName: "exclamationmark.triangle") + } + if error == .unauthorized { + Button("Erneut anmelden", action: onLogin) + } else if error.allowsRetry { + Button("Übersicht aktualisieren", action: onRetry) + } + } + .font(.callout) + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(Color.orange.opacity(0.12), in: .rect(cornerRadius: 12)) + } +} diff --git a/GasTankAR/Views/Leads/Overview/LeadOverviewView.swift b/GasTankAR/Views/Leads/Overview/LeadOverviewView.swift new file mode 100644 index 0000000..6bb130b --- /dev/null +++ b/GasTankAR/Views/Leads/Overview/LeadOverviewView.swift @@ -0,0 +1,223 @@ +import SwiftUI + +struct LeadOverviewView: View { + let session: LoginSession + let onLogout: () -> Void + private let repository: any LeadRepository + private let detailRepository: any LeadDetailRepository + + @State private var model: LeadOverviewViewModel + @State private var refreshID = 0 + @FocusState private var isSearchFocused: Bool + @State private var searchFieldFrame: CGRect = .zero + @State private var isPulledDown = false + @Namespace private var cardMotion + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var pendingBookmark: Lead? + @State private var flight: LeadCardFlight? + @State private var flightShowsBookmark = false + @State private var selectedLead: Lead? + @State private var isShowingDetail = false + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + + init(session: LoginSession, repository: any LeadRepository, + detailRepository: (any LeadDetailRepository)? = nil, + onLogout: @escaping () -> Void) { + self.detailRepository = detailRepository ?? DefaultLeadDetailRepository(remoteService: StubLeadDetailRemoteService()) + self.repository = repository + self.session = session + self.onLogout = onLogout + _model = State(initialValue: LeadOverviewViewModel(salesCode: session.salesCode, repository: repository)) + } + + var body: some View { + @Bindable var model = model + GeometryReader { geometry in + let columnCount = dynamicTypeSize.isAccessibilitySize ? 1 : min(3, max(1, Int((geometry.size.width - 48) / 310))) + let columns = Array(repeating: GridItem(.flexible(), spacing: 16, alignment: .top), count: columnCount) + List { + VStack(alignment: .leading, spacing: 24) { + LeadOverviewHeaderView(session: session, lastUpdated: model.lastUpdated, + isLoading: model.isLoading, onRefresh: refresh, onLogout: onLogout) + LeadSearchField(text: $model.searchText, isFocused: $isSearchFocused, + onClear: model.clearSearchText) + .onGeometryChange(for: CGRect.self) { geometry in + geometry.frame(in: .global) + } action: { frame in + searchFieldFrame = frame + } + + if let error = model.actionError { + LeadErrorBanner(error: error, onRetry: { + model.dismissActionError() + refresh() + }, onLogin: onLogout) + } + + if model.hasCurrentResults && !model.isSessionExpired { + if let error = model.error { + LeadErrorBanner(error: error, onRetry: refresh, onLogin: onLogout) + } + BookmarkedLeadsSection(leads: model.bookmarkedLeads, columns: columns, + flyingLeadID: flight?.lead.id, + cardMotion: cardMotion, + isBusy: pendingBookmark != nil, + onOpen: open, onBookmark: toggleBookmark) + .zIndex(1) + LeadResultsSection(leads: model.leads, columns: columns, selection: $model.selection, + cardMotion: cardMotion, + isBusy: pendingBookmark != nil, + onOpen: open, onBookmark: toggleBookmark) + if model.leads.isEmpty { + LeadOverviewStateView(error: nil, isLoading: false, + isSearching: !model.query.searchText.isEmpty || model.selection == .open, + onRetry: refresh, onReset: model.resetSearch, onLogin: onLogout) + } + } else { + LeadOverviewStateView(error: model.error ?? model.actionError, + isLoading: model.isLoading || (model.error == nil && model.actionError == nil), + isSearching: !model.query.searchText.isEmpty || model.selection == .open, + onRetry: refresh, onReset: model.resetSearch, onLogin: onLogout) + } + } + .padding(24) + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .scrollBounceBehavior(.always, axes: .vertical) + .refreshable { [model] in + guard !model.isSessionExpired else { return } + await model.reload() + } + .onScrollGeometryChange(for: Bool.self) { geometry in + geometry.contentOffset.y + geometry.contentInsets.top < -4 + } action: { _, pulledDown in + isPulledDown = pulledDown + } + .overlay(alignment: .top) { + if model.isLoading && model.hasCurrentResults && !isPulledDown { + ProgressView() + .accessibilityLabel("Interessenten werden aktualisiert") + .padding(12) + .background(.regularMaterial, in: .circle) + .padding(.top, 4) + .allowsHitTesting(false) + } + } + .scrollDisabled(pendingBookmark != nil) + .overlay(alignment: .topLeading) { + if let flight { + LeadCardFlightView(lead: flight.lead, showsBookmark: flightShowsBookmark) + .matchedGeometryEffect(id: "flight-\(flight.lead.id)", in: cardMotion, + properties: .frame, isSource: false) + .allowsHitTesting(false) + .accessibilityHidden(true) + } + } + .scrollDismissesKeyboard(.interactively) + .background(LeadOverviewStyle.background.ignoresSafeArea()) + } + .simultaneousGesture( + SpatialTapGesture(coordinateSpace: .global) + .onEnded { value in + if !searchFieldFrame.contains(value.location) { + isSearchFocused = false + } + } + ) + .tint(LeadOverviewStyle.blue) + .navigationTitle("Interessenten") + .toolbar(.hidden, for: .navigationBar) + .task(id: model.query) { + await model.reload(debounce: true, useCachedList: true) + } + .task(id: refreshID) { + guard refreshID > 0 else { return } + await model.reload() + } + .task(id: pendingBookmark?.id) { + guard let lead = pendingBookmark else { return } + guard let updated = await model.saveBookmark(lead), !Task.isCancelled else { + pendingBookmark = nil + return + } + if reduceMotion { + withAnimation(.easeInOut(duration: 0.2)) { model.applyBookmark(updated) } + pendingBookmark = nil + return + } + // Establish a copy at the unchanged source before the single layout transaction. + flightShowsBookmark = lead.isBookmarked + flight = LeadCardFlight(lead: lead) + try? await Task.sleep(for: .milliseconds(20)) + guard !Task.isCancelled else { + flight = nil + pendingBookmark = nil + return + } + // Unlock at the animation's logical end, without waiting for the spring's settling tail. + withAnimation(.smooth(duration: 0.55), completionCriteria: .logicallyComplete) { + model.applyBookmark(updated) + flightShowsBookmark = updated.isBookmarked + } completion: { + flight = nil + pendingBookmark = nil + } + } + .navigationDestination(isPresented: $isShowingDetail) { + if let selectedLead { + LeadDetailScreen(leadID: selectedLead.id, salesCode: session.salesCode, + repository: detailRepository, onLogout: onLogout) + .id(selectedLead.id) + .toolbar(.visible, for: .navigationBar) + } + } + } + + private func refresh() { refreshID += 1 } + + private func open(_ lead: Lead) { + guard pendingBookmark == nil, !isShowingDetail, !model.isSessionExpired else { return } + selectedLead = lead + isShowingDetail = true + } + + private func toggleBookmark(_ lead: Lead) { + guard pendingBookmark == nil, !model.isSessionExpired else { return } + pendingBookmark = lead + } + +} + +#Preview("Übersicht", traits: .fixedLayout(width: 1194, height: 834)) { + NavigationStack { + LeadOverviewView( + session: LoginSession(salesCode: "MKR", displayName: "M. Krämer"), + repository: DefaultLeadRepository(remoteService: StubLeadRemoteService(delay: .zero)), + onLogout: {} + ) + } +} + +#Preview("Keine Interessenten", traits: .fixedLayout(width: 1194, height: 834)) { + NavigationStack { + LeadOverviewView( + session: LoginSession(salesCode: "MKR", displayName: "M. Krämer"), + repository: DefaultLeadRepository(remoteService: StubLeadRemoteService(delay: .zero, records: [])), + onLogout: {} + ) + } +} + +#Preview("Serverfehler", traits: .fixedLayout(width: 1194, height: 834)) { + NavigationStack { + LeadOverviewView( + session: LoginSession(salesCode: "MKR"), + repository: DefaultLeadRepository(remoteService: StubLeadRemoteService(scenario: .http(500), delay: .zero)), + onLogout: {} + ) + } +} diff --git a/GasTankAR/Views/Leads/Overview/LeadOverviewViewModel.swift b/GasTankAR/Views/Leads/Overview/LeadOverviewViewModel.swift new file mode 100644 index 0000000..8f4f0f4 --- /dev/null +++ b/GasTankAR/Views/Leads/Overview/LeadOverviewViewModel.swift @@ -0,0 +1,173 @@ +import Foundation +import Observation + +@MainActor +@Observable +final class LeadOverviewViewModel { + var searchText = "" { + didSet { + guard searchText != oldValue else { return } + if query.searchText.isEmpty { restoreCachedList() } + } + } + var selection: LeadOverviewSelection = .newest { + didSet { updateDisplayedLeads() } + } + private(set) var leads: [Lead] = [] + private(set) var bookmarkedLeads: [Lead] = [] + private(set) var isLoading = false + private(set) var lastUpdated: Date? + private(set) var error: LeadError? + private(set) var actionError: LeadError? + private(set) var bookmarkingIDs: Set = [] + private var loadedLeads: [Lead] = [] + private var cachedList: [Lead]? + private var cachedListUpdatedAt: Date? + private var displayedQuery: LeadQuery? + private var generation = 0 + private var bookmarkRevision = 0 + private var confirmedBookmarks: [String: (revision: Int, value: Bool)] = [:] + + @ObservationIgnored private let salesCode: String + @ObservationIgnored private let load: LoadLeadsUseCase + @ObservationIgnored private let search: SearchLeadsUseCase + @ObservationIgnored private let bookmark: SetLeadBookmarkUseCase + + init(salesCode: String, repository: any LeadRepository) { + self.salesCode = salesCode + load = LoadLeadsUseCase(repository: repository) + search = SearchLeadsUseCase(repository: repository) + bookmark = SetLeadBookmarkUseCase(repository: repository) + } + + var query: LeadQuery { + // Only the search term affects the server request; sorting and filtering stay local. + LeadQuery(searchText: searchText).normalized + } + + var hasCurrentResults: Bool { displayedQuery == query } + var isSessionExpired: Bool { error == .unauthorized || actionError == .unauthorized } + + func reload(debounce: Bool = false, useCachedList: Bool = false) async { + if useCachedList && query.searchText.isEmpty && cachedList != nil && !isSessionExpired { + restoreCachedList() + return + } + generation += 1 + let request = generation + let requestedBookmarkRevision = bookmarkRevision + let requestedQuery = query + isLoading = true + error = nil + defer { + if request == generation { isLoading = false } + } + do { + if debounce && !requestedQuery.searchText.isEmpty { + try await Task.sleep(for: .milliseconds(300)) + } + try Task.checkCancellation() + let result: [Lead] + if requestedQuery == LeadQuery() { + result = try await load.execute(salesCode: salesCode) + } else { + result = try await search.execute(salesCode: salesCode, query: requestedQuery) + } + try Task.checkCancellation() + // Query comparison also protects the interval before the next .task starts. + guard request == generation, requestedQuery == query else { return } + loadedLeads = result.map { lead in + var lead = lead + if let confirmed = confirmedBookmarks[lead.id], + confirmed.revision > requestedBookmarkRevision { + lead.isBookmarked = confirmed.value + } + return lead + } + updateDisplayedLeads() + displayedQuery = requestedQuery + lastUpdated = Date() + if requestedQuery.searchText.isEmpty { + cachedList = loadedLeads + cachedListUpdatedAt = lastUpdated + } + } catch is CancellationError { + // A superseded search must not replace data or present an error. + } catch { + guard request == generation, requestedQuery == query else { return } + self.error = error as? LeadError ?? .unexpected + } + } + + func toggleBookmark(_ lead: Lead) async { + if let updated = await saveBookmark(lead) { + applyBookmark(updated) + } + } + + func saveBookmark(_ lead: Lead) async -> Lead? { + guard !bookmarkingIDs.contains(lead.id), !isSessionExpired else { return nil } + bookmarkingIDs.insert(lead.id) + actionError = nil + defer { bookmarkingIDs.remove(lead.id) } + do { + let updated = try await bookmark.execute(!lead.isBookmarked, leadID: lead.id, salesCode: salesCode) + try Task.checkCancellation() + return updated + } catch is CancellationError { + return nil + } catch { + actionError = error as? LeadError ?? .unexpected + return nil + } + } + + func applyBookmark(_ updated: Lead) { + bookmarkRevision += 1 + confirmedBookmarks[updated.id] = (bookmarkRevision, updated.isBookmarked) + if let index = cachedList?.firstIndex(where: { $0.id == updated.id }) { + cachedList?[index] = updated + } + if let index = loadedLeads.firstIndex(where: { $0.id == updated.id }) { + loadedLeads[index] = updated + updateDisplayedLeads() + } + } + + private func updateDisplayedLeads() { + let filtered = selection == .open + ? loadedLeads.filter { $0.unsignedContractCount > 0 } + : loadedLeads + leads = filtered.sorted { lhs, rhs in + if selection == .alphabetical { + let order = lhs.displayName.localizedStandardCompare(rhs.displayName) + return order == .orderedSame ? lhs.id < rhs.id : order == .orderedAscending + } + return lhs.createdAt == rhs.createdAt ? lhs.id < rhs.id : lhs.createdAt > rhs.createdAt + } + bookmarkedLeads = leads.filter(\.isBookmarked) + } + + func dismissActionError() { actionError = nil } + + private func restoreCachedList() { + guard let cachedList, !isSessionExpired else { return } + // Invalidate in-flight searches before publishing the original list. + generation += 1 + loadedLeads = cachedList + displayedQuery = LeadQuery() + lastUpdated = cachedListUpdatedAt + error = nil + isLoading = false + updateDisplayedLeads() + } + + func clearSearchText() { + searchText = "" + } + + func resetSearch() { + clearSearchText() + selection = .newest + } +} diff --git a/GasTankAR/Views/Leads/Overview/LeadPresentation.swift b/GasTankAR/Views/Leads/Overview/LeadPresentation.swift new file mode 100644 index 0000000..25c4cec --- /dev/null +++ b/GasTankAR/Views/Leads/Overview/LeadPresentation.swift @@ -0,0 +1,53 @@ +import SwiftUI + +enum LeadOverviewStyle { + static let blue = Color(red: 0, green: 0.44, blue: 0.80) + static let successGreen = Color(red: 0.12, green: 0.53, blue: 0.38) + static let background = Color(uiColor: .systemGroupedBackground) +} + +/// Formatting shared by the overview and the selected lead's detail screen. +extension Lead { + var displayName: String { + companyName ?? [contact?.title, contact?.firstName, contact?.lastName] + .compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " ") + } + + var locality: String { "\(address.postalCode) \(address.city)" } + + var fullAddress: String { + [address.street, locality].compactMap { $0 }.joined(separator: ", ") + } +} + +extension LeadError { + var message: LocalizedStringResource { + switch self { + case .offline: "Keine Internetverbindung. Bitte prüfe deine Verbindung." + case .timeout: "Der Server antwortet derzeit nicht. Bitte versuche es erneut." + case .serviceUnavailable: "Die Interessenten konnten gerade nicht geladen werden." + case .unauthorized: "Deine Sitzung ist abgelaufen. Bitte melde dich erneut an." + case .forbidden: "Du hast keine Berechtigung für diesen Vorgang." + case .notFound: "Dieser Interessent ist nicht mehr verfügbar. Bitte aktualisiere die Übersicht." + case .invalidResponse: "Die Serverantwort konnte nicht verarbeitet werden." + case .unexpected: "Ein unerwarteter Fehler ist aufgetreten. Bitte versuche es erneut." + } + } + + var allowsRetry: Bool { + self != .unauthorized && self != .forbidden + } +} + +enum LeadOverviewSelection: String, CaseIterable, Identifiable { + case newest, alphabetical, open + var id: Self { self } + + var title: LocalizedStringKey { + switch self { + case .newest: "Neueste" + case .alphabetical: "A–Z" + case .open: "Offen" + } + } +} diff --git a/GasTankAR/Views/Leads/Overview/LeadResultsSection.swift b/GasTankAR/Views/Leads/Overview/LeadResultsSection.swift new file mode 100644 index 0000000..cdafc56 --- /dev/null +++ b/GasTankAR/Views/Leads/Overview/LeadResultsSection.swift @@ -0,0 +1,51 @@ +import SwiftUI + +struct LeadResultsSection: View { + let leads: [Lead] + let columns: [GridItem] + @Binding var selection: LeadOverviewSelection + let cardMotion: Namespace.ID + let isBusy: Bool + let onOpen: (Lead) -> Void + let onBookmark: (Lead) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + ViewThatFits(in: .horizontal) { + HStack { + heading + Spacer(minLength: 20) + picker.frame(width: 300) + } + VStack(alignment: .leading, spacing: 12) { + heading + picker + } + } + LeadCardGrid(columnCount: columns.count) { + ForEach(leads) { lead in + LeadCardView(lead: lead, isBusy: isBusy, + onOpen: { onOpen(lead) }, onBookmark: { onBookmark(lead) }) + .matchedGeometryEffect(id: lead.isBookmarked ? "regular-\(lead.id)" : "flight-\(lead.id)", + in: cardMotion) + } + } + } + } + + private var heading: some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text("Alle Interessenten").font(.title2.bold()) + Text(leads.count, format: .number).foregroundStyle(.secondary) + } + } + + private var picker: some View { + Picker("Sortierung und Filter", selection: $selection) { + ForEach(LeadOverviewSelection.allCases) { option in + Text(option.title).tag(option) + } + } + .pickerStyle(.segmented) + } +} diff --git a/GasTankAR/Views/Leads/Overview/LeadSearchField.swift b/GasTankAR/Views/Leads/Overview/LeadSearchField.swift new file mode 100644 index 0000000..fe785ff --- /dev/null +++ b/GasTankAR/Views/Leads/Overview/LeadSearchField.swift @@ -0,0 +1,35 @@ +import SwiftUI + +struct LeadSearchField: View { + @Binding var text: String + var isFocused: FocusState.Binding + let onClear: () -> Void + + var body: some View { + HStack(spacing: 12) { + Image(systemName: "magnifyingglass") + .accessibilityHidden(true) + TextField("Firma, Person, Ort oder LeadID", text: $text) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .submitLabel(.search) + .focused(isFocused) + .onSubmit { isFocused.wrappedValue = false } + .accessibilityLabel("Interessenten suchen") + if !text.isEmpty { + Button(action: onClear) { + Image(systemName: "xmark.circle.fill") + .frame(width: 44, height: 44) + .contentShape(.rect) + } + .buttonStyle(.plain) + .accessibilityLabel("Suche löschen") + .padding(.vertical, -10) + } + } + .font(.title3) + .foregroundStyle(.secondary) + .padding(18) + .background(Color.primary.opacity(0.06), in: .rect(cornerRadius: 15)) + } +} diff --git a/GasTankAR/Views/Leads/Overview/LeadStatusView.swift b/GasTankAR/Views/Leads/Overview/LeadStatusView.swift new file mode 100644 index 0000000..45763bf --- /dev/null +++ b/GasTankAR/Views/Leads/Overview/LeadStatusView.swift @@ -0,0 +1,73 @@ +import SwiftUI + +struct LeadStatusView: View { + let offerCount: Int + let unsignedContractCount: Int + var highlighted = false + + var body: some View { + ViewThatFits(in: .horizontal) { + HStack(spacing: 8) { + LeadOfferBadge(count: offerCount, highlighted: highlighted) + contractStatus + } + VStack(alignment: .leading, spacing: 8) { + LeadOfferBadge(count: offerCount, highlighted: highlighted) + contractStatus + } + } + .font(.footnote) + } + + @ViewBuilder + private var contractStatus: some View { + if offerCount > 0 && unsignedContractCount == 0 { + HStack(spacing: 5) { + Image(systemName: "checkmark.circle.fill") + .symbolRenderingMode(.palette) + .foregroundStyle(.white, LeadOverviewStyle.successGreen) + .font(.title3) + Text("signiert") + .foregroundStyle(highlighted ? Color.white.opacity(0.9) : Color.secondary) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel("Alle Verträge unterschrieben") + } else { + Text(contractLabel) + .foregroundStyle(highlighted ? Color.white.opacity(0.9) : Color.secondary) + } + } + + private var contractLabel: LocalizedStringResource { + switch unsignedContractCount { + case 0: "Nichts zu unterschreiben" + case 1: "1 Vertrag offen" + default: "\(unsignedContractCount) Verträge offen" + } + } +} + +private struct LeadOfferBadge: View { + let count: Int + let highlighted: Bool + + var body: some View { + Text(label) + .fontWeight(.medium) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .foregroundStyle(highlighted ? .white : (count == 0 ? Color.secondary : LeadOverviewStyle.blue)) + .background( + highlighted ? Color.white.opacity(0.25) : (count == 0 ? Color.primary.opacity(0.05) : LeadOverviewStyle.blue.opacity(0.08)), + in: .capsule + ) + } + + private var label: LocalizedStringResource { + switch count { + case 0: "Kein Angebot" + case 1: "1 Angebot" + default: "\(count) Angebote" + } + } +} diff --git a/GasTankAR/Views/Leads/Shared/LeadDocumentPreview.swift b/GasTankAR/Views/Leads/Shared/LeadDocumentPreview.swift new file mode 100644 index 0000000..32c54bf --- /dev/null +++ b/GasTankAR/Views/Leads/Shared/LeadDocumentPreview.swift @@ -0,0 +1,52 @@ +import SwiftUI +import QuickLook + +/// Native full-screen PDF reading, including zoom, page navigation and system open-in actions. +struct LeadDocumentPreview: View { + let url: URL + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + LeadQuickLookView(url: url) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(role: .confirm) { dismiss() } + .labelStyle(.iconOnly) + .accessibilityLabel("Zurück zur Unterschrift") + } + } + } + } +} + +private struct LeadQuickLookView: UIViewControllerRepresentable { + let url: URL + + func makeUIViewController(context: Context) -> QLPreviewController { + let controller = QLPreviewController() + controller.dataSource = context.coordinator + return controller + } + + func updateUIViewController(_ controller: QLPreviewController, context: Context) { + guard context.coordinator.url != url else { return } + context.coordinator.url = url + controller.reloadData() + } + + func makeCoordinator() -> Coordinator { Coordinator(url: url) } + + final class Coordinator: NSObject, QLPreviewControllerDataSource { + var url: URL + + init(url: URL) { self.url = url } + + func numberOfPreviewItems(in controller: QLPreviewController) -> Int { 1 } + + func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> any QLPreviewItem { + url as NSURL + } + } +} diff --git a/GasTankAR/Views/Leads/Shared/LeadPDFView.swift b/GasTankAR/Views/Leads/Shared/LeadPDFView.swift new file mode 100644 index 0000000..94744dc --- /dev/null +++ b/GasTankAR/Views/Leads/Shared/LeadPDFView.swift @@ -0,0 +1,72 @@ +import SwiftUI +import PDFKit + +struct LeadPDFView: UIViewRepresentable { + let url: URL + var signaturePlacement: PDFSignaturePlacement? + var fitsWholePage = false + var onPageChanged: ((Int, Int) -> Void)? + + func makeUIView(context: Context) -> PDFView { + let view = PDFView() + view.displayMode = fitsWholePage ? .singlePage : .singlePageContinuous + if fitsWholePage { + view.displayDirection = .horizontal + view.usePageViewController(true) + } + view.autoScales = true + view.backgroundColor = .secondarySystemGroupedBackground + context.coordinator.observe(view) + return view + } + + func updateUIView(_ view: PDFView, context: Context) { + context.coordinator.onPageChanged = onPageChanged + guard view.document?.documentURL != url else { return } + view.document = PDFDocument(url: url) + view.autoScales = true + if let signaturePlacement, + let page = view.document?.page(at: signaturePlacement.pageIndex) { + // The marker exists only in the preview; the signing service reads the original file. + let marker = PDFAnnotation(bounds: signaturePlacement.bounds, forType: .square, withProperties: nil) + marker.color = .systemOrange + marker.interiorColor = UIColor.systemOrange.withAlphaComponent(0.08) + marker.contents = "Unterschrift Kunde" + let border = PDFBorder() + border.lineWidth = 1 + border.style = .dashed + border.dashPattern = [3, 2] + marker.border = border + page.addAnnotation(marker) + view.go(to: page) + } + context.coordinator.report(view) + } + + func makeCoordinator() -> Coordinator { Coordinator() } + + final class Coordinator: NSObject { + var onPageChanged: ((Int, Int) -> Void)? + + func observe(_ view: PDFView) { + NotificationCenter.default.addObserver( + self, selector: #selector(pageChanged(_:)), name: .PDFViewPageChanged, object: view + ) + } + + @objc private func pageChanged(_ notification: Notification) { + guard let view = notification.object as? PDFView else { return } + report(view) + } + + func report(_ view: PDFView) { + guard let document = view.document, let page = view.currentPage else { return } + let number = document.index(for: page) + 1 + let count = document.pageCount + // Avoid changing SwiftUI state while the representable is updating. + Task { @MainActor [weak self] in self?.onPageChanged?(number, count) } + } + + deinit { NotificationCenter.default.removeObserver(self) } + } +} diff --git a/GasTankAR/Views/Leads/Signing/LeadSignatureCanvas.swift b/GasTankAR/Views/Leads/Signing/LeadSignatureCanvas.swift new file mode 100644 index 0000000..436100e --- /dev/null +++ b/GasTankAR/Views/Leads/Signing/LeadSignatureCanvas.swift @@ -0,0 +1,44 @@ +import SwiftUI +import PencilKit + +struct LeadSignatureCanvas: UIViewRepresentable { + @Binding var drawing: PKDrawing + @Environment(\.colorScheme) private var colorScheme + + func makeUIView(context: Context) -> PKCanvasView { + let view = PKCanvasView() + view.delegate = context.coordinator + view.drawingPolicy = .anyInput + // PencilKit renders black ink as white in Dark Mode, including existing strokes. + // Keep the stored ink black; the PDF service exports onto a white document. + view.tool = PKInkingTool(.pen, color: .black, width: 2) + view.overrideUserInterfaceStyle = colorScheme == .dark ? .dark : .light + view.backgroundColor = .clear + view.isOpaque = false + view.isScrollEnabled = false + view.drawing = drawing + view.accessibilityLabel = "Unterschrift" + view.accessibilityHint = "Mit Finger oder Apple Pencil unterschreiben" + return view + } + + func updateUIView(_ view: PKCanvasView, context: Context) { + context.coordinator.parent = self + view.overrideUserInterfaceStyle = colorScheme == .dark ? .dark : .light + if view.drawing != drawing { + view.drawing = drawing + } + } + + func makeCoordinator() -> Coordinator { Coordinator(parent: self) } + + final class Coordinator: NSObject, PKCanvasViewDelegate { + var parent: LeadSignatureCanvas + + init(parent: LeadSignatureCanvas) { self.parent = parent } + + func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) { + parent.drawing = canvasView.drawing + } + } +} diff --git a/GasTankAR/Views/Leads/Signing/LeadSigningCompletionView.swift b/GasTankAR/Views/Leads/Signing/LeadSigningCompletionView.swift new file mode 100644 index 0000000..4a6267b --- /dev/null +++ b/GasTankAR/Views/Leads/Signing/LeadSigningCompletionView.swift @@ -0,0 +1,43 @@ +import SwiftUI + +struct LeadSigningCompletionView: View { + let model: LeadSigningViewModel + + var body: some View { + VStack(spacing: 16) { + if model.isBusy { + ProgressView() + .controlSize(.large) + Text(model.isUploading ? "PDFs werden hochgeladen …" : "PDFs werden signiert …") + .font(.headline) + if model.isDemoUpload { + Text("Demo-Modus: Der Upload wird simuliert.") + .font(.subheadline).foregroundStyle(.secondary) + } + } else if let error = model.uploadError { + Label("Upload nicht abgeschlossen", systemImage: "exclamationmark.triangle") + .font(.headline) + Text(error).foregroundStyle(.secondary) + Button("Erneut versuchen") { + Task { await model.uploadSignedDocuments() } + } + .buttonStyle(.borderedProminent) + } else if let result = model.uploadResult { + Image(systemName: "checkmark.circle.fill") + .font(.largeTitle).foregroundStyle(.green) + switch result { + case .uploaded: + Text("PDFs signiert und hochgeladen").font(.headline) + case .simulated: + Text("PDFs signiert und lokal gespeichert").font(.headline) + Text("Demo-Upload abgeschlossen. Es wurden keine PDFs übertragen.") + .font(.subheadline).foregroundStyle(.secondary) + } + } + } + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, minHeight: 220) + .padding(24) + .accessibilityElement(children: .contain) + } +} diff --git a/GasTankAR/Views/Leads/Signing/LeadSigningDocumentListView.swift b/GasTankAR/Views/Leads/Signing/LeadSigningDocumentListView.swift new file mode 100644 index 0000000..b1ca1de --- /dev/null +++ b/GasTankAR/Views/Leads/Signing/LeadSigningDocumentListView.swift @@ -0,0 +1,60 @@ +import SwiftUI + +struct LeadSigningDocumentListView: View { + let documents: [LeadDocument] + let currentID: String? + let signedIDs: Set + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("DOKUMENTE").font(.caption).foregroundStyle(.secondary) + .padding(.horizontal, 6) + VStack(spacing: 0) { + ForEach(Array(documents.enumerated()), id: \.element.id) { index, document in + LeadSigningDocumentRow( + title: document.title.replacingOccurrences(of: ".pdf", with: ""), + number: index + 1, + isCurrent: document.id == currentID && document.status != .signed && !signedIDs.contains(document.id), + isSigned: document.status == .signed || signedIDs.contains(document.id) + ) + if document.id != documents.last?.id { + Divider().padding(.leading, 44) + } + } + } + .background(Color(uiColor: .secondarySystemGroupedBackground), in: .rect(cornerRadius: 12)) + .clipShape(.rect(cornerRadius: 12)) + } + .padding(12) + } +} + +struct LeadSigningDocumentRow: View { + let title: String + let number: Int + let isCurrent: Bool + let isSigned: Bool + + var body: some View { + HStack(spacing: 12) { + if isSigned { + Image(systemName: "checkmark.circle.fill").foregroundStyle(.green).frame(width: 20) + } else { + Text(number.formatted()).foregroundStyle(isCurrent ? Color.accentColor : .secondary) + .frame(width: 20) + } + VStack(alignment: .leading, spacing: 3) { + Text(title).fontWeight(isCurrent ? .semibold : .regular).lineLimit(2) + Text(isSigned ? "Unterschrieben" : isCurrent ? "Jetzt unterschreiben" : "Ausstehend") + .font(.caption) + .foregroundStyle(isSigned ? Color.green : isCurrent ? Color.accentColor : .secondary) + } + Spacer(minLength: 0) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(isCurrent ? Color.accentColor.opacity(0.10) : .clear) + .accessibilityElement(children: .combine) + .accessibilityAddTraits(isCurrent ? .isSelected : []) + } +} diff --git a/GasTankAR/Views/Leads/Signing/LeadSigningFooterView.swift b/GasTankAR/Views/Leads/Signing/LeadSigningFooterView.swift new file mode 100644 index 0000000..e8005cc --- /dev/null +++ b/GasTankAR/Views/Leads/Signing/LeadSigningFooterView.swift @@ -0,0 +1,28 @@ +import SwiftUI + +struct LeadSigningFooterView: View { + let title: String + let canSign: Bool + let isSigning: Bool + let isLast: Bool + let onSign: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Mit der Unterschrift bestätigt der Kunde den Inhalt von „\(title)“. Die unterschriebene PDF wird lokal gespeichert.") + .font(.caption).foregroundStyle(.secondary) + .padding(.horizontal, 16) + Button(action: onSign) { + HStack { + if isSigning { ProgressView().tint(.white) } + Text(isLast ? "Unterschreiben und abschließen" : "Unterschreiben und weiter") + .font(.headline) + } + .frame(maxWidth: .infinity).padding(.vertical, 12) + } + .buttonStyle(.borderedProminent) + .buttonBorderShape(.roundedRectangle(radius: 12)) + .disabled(!canSign) + } + } +} diff --git a/GasTankAR/Views/Leads/Signing/LeadSigningHeaderView.swift b/GasTankAR/Views/Leads/Signing/LeadSigningHeaderView.swift new file mode 100644 index 0000000..a73c9fd --- /dev/null +++ b/GasTankAR/Views/Leads/Signing/LeadSigningHeaderView.swift @@ -0,0 +1,34 @@ +import SwiftUI + +struct LeadSigningHeaderView: ToolbarContent { + let offerID: String + let current: Int + let total: Int + let isComplete: Bool + let isSigning: Bool + let canConfirm: Bool + let onClose: () -> Void + + var body: some ToolbarContent { + ToolbarItem(placement: .cancellationAction) { + Button(role: .cancel, action: onClose) + .labelStyle(.iconOnly) + .disabled(isSigning) + } + ToolbarItem(placement: .principal) { + VStack(spacing: 3) { + Text(offerID).font(.headline) + if isComplete { + Text("Alle Dokumente unterschrieben").font(.caption).foregroundStyle(.secondary) + } else { + Text("Dokument \(current) von \(total)").font(.caption).foregroundStyle(.secondary) + } + } + } + ToolbarItem(placement: .confirmationAction) { + Button(role: .confirm, action: onClose) + .labelStyle(.iconOnly) + .disabled(!canConfirm) + } + } +} diff --git a/GasTankAR/Views/Leads/Signing/LeadSigningPreviewView.swift b/GasTankAR/Views/Leads/Signing/LeadSigningPreviewView.swift new file mode 100644 index 0000000..e5a43fa --- /dev/null +++ b/GasTankAR/Views/Leads/Signing/LeadSigningPreviewView.swift @@ -0,0 +1,42 @@ +import SwiftUI +import PDFKit +import QuickLook + +struct LeadSigningPreviewView: View { + let offerID: String + let url: URL + let placement: PDFSignaturePlacement? + @State private var showsPreview = false + @State private var pageNumber = 1 + @State private var pageCount = 1 + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("VORSCHAU · \(offerID)") + Spacer() + Button("In Vorschau öffnen", systemImage: "arrow.up.left.and.arrow.down.right") { + showsPreview = true + } + .font(.subheadline) + .foregroundStyle(.tint) + } + .font(.caption).foregroundStyle(.secondary).padding(.horizontal, 16) + LeadPDFView(url: url, signaturePlacement: placement, fitsWholePage: true) { current, total in + pageNumber = current + pageCount = total + } + .frame(height: 310) + .clipShape(.rect(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(.gray.opacity(0.25))) + Text("Seite \(pageNumber) von \(pageCount)") + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .trailing) + .padding(.horizontal, 16) + } + .fullScreenCover(isPresented: $showsPreview) { + LeadDocumentPreview(url: url) + } + } +} diff --git a/GasTankAR/Views/Leads/Signing/LeadSigningSheet.swift b/GasTankAR/Views/Leads/Signing/LeadSigningSheet.swift new file mode 100644 index 0000000..a4298c0 --- /dev/null +++ b/GasTankAR/Views/Leads/Signing/LeadSigningSheet.swift @@ -0,0 +1,156 @@ +import SwiftUI +import PencilKit + +struct LeadSigningSheet: View { + @State private var model: LeadSigningViewModel + @State private var confirmDiscard = false + @Environment(\.dismiss) private var dismiss + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + let onSigned: (LeadDocument, URL) -> Void + + init(request: LeadSigningRequest, leadID: String, salesCode: String, + repository: any LeadDetailRepository, onSigned: @escaping (LeadDocument, URL) -> Void) { + _model = State(initialValue: LeadSigningViewModel( + request: request, leadID: leadID, salesCode: salesCode, repository: repository + )) + self.onSigned = onSigned + } + + init(model: LeadSigningViewModel, onSigned: @escaping (LeadDocument, URL) -> Void) { + _model = State(initialValue: model) + self.onSigned = onSigned + } + + var body: some View { + NavigationStack { + GeometryReader { geometry in + if geometry.size.width >= 700 && !dynamicTypeSize.isAccessibilitySize { + HStack(alignment: .top, spacing: 0) { + ScrollView { + LeadSigningDocumentListView( + documents: model.request.documents, currentID: model.currentDocument?.id, + signedIDs: Set(model.signedURLs.keys) + ) + } + .frame(width: 240) + Divider() + ScrollView { + LeadSigningContentView(model: model, onSigned: onSigned) + .padding(16) + } + } + } else { + ScrollView { + VStack(spacing: 0) { + LeadSigningDocumentListView( + documents: model.request.documents, currentID: model.currentDocument?.id, + signedIDs: Set(model.signedURLs.keys) + ) + LeadSigningContentView(model: model, onSigned: onSigned) + .padding(16) + } + } + } + } + .background(Color(uiColor: .systemGroupedBackground)) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + LeadSigningHeaderView( + offerID: model.request.offerID, current: model.currentDocumentNumber, + total: model.request.documents.count, isComplete: model.isComplete, + isSigning: model.isBusy, canConfirm: model.canFinish + ) { + if model.drawing.strokes.isEmpty { dismiss() } else { confirmDiscard = true } + } + } + } + .tint(LeadDetailStyle.blue) + .frame(idealWidth: 1000, idealHeight: 790) + .presentationSizing(.fitted) + .presentationCornerRadius(20) + .interactiveDismissDisabled(model.isBusy || !model.drawing.strokes.isEmpty) + .confirmationDialog("Unterschrift verwerfen?", isPresented: $confirmDiscard, titleVisibility: .visible) { + Button("Unterschrift verwerfen", role: .destructive) { dismiss() } + Button("Weiter unterschreiben", role: .cancel) {} + } message: { + Text("Bereits gespeicherte PDFs bleiben erhalten. Nur die aktuelle Eingabe wird verworfen.") + } + .task { + if model.sourceURL == nil { await model.loadCurrentDocument() } + } + } +} + +#Preview("Unterschreiben", traits: .fixedLayout(width: 1000, height: 790), .modifier(LeadSigningPreviewData())) { + LeadSigningPreviewHost() +} + +private struct LeadSigningPreviewHost: View { + @Environment(LeadSigningViewModel.self) private var model + + var body: some View { + LeadSigningSheet(model: model, onSigned: { _, _ in }) + } +} + +private struct LeadSigningPreviewData: PreviewModifier { + static func makeSharedContext() async throws -> LeadSigningViewModel { + let repository = DefaultLeadDetailRepository( + remoteService: StubLeadDetailRemoteService(delay: .zero) + ) + let detail = try await repository.detail(id: "L-240318", salesCode: "MKR") + guard let offer = detail.offers.first else { throw PDFSigningError.invalidDocument } + let request = LeadSigningRequest(offerID: offer.id, signerName: "Klaus Hoffmann", + documents: offer.documents) + let model = LeadSigningViewModel(request: request, leadID: detail.lead.id, + salesCode: "MKR", repository: repository) + await model.loadCurrentDocument() + return model + } + + func body(content: Content, context: LeadSigningViewModel) -> some View { + content.environment(context) + } +} + +private struct LeadSigningContentView: View { + @Bindable var model: LeadSigningViewModel + let onSigned: (LeadDocument, URL) -> Void + + var body: some View { + VStack(spacing: 20) { + if let url = model.sourceURL { + LeadSigningPreviewView( + offerID: model.request.offerID, url: url, + placement: model.isComplete ? nil : model.currentDocument?.signaturePlacement + ) + } else if model.isLoading { + ProgressView("PDF wird geladen …") + .frame(maxWidth: .infinity, minHeight: 310) + } + if model.showsCompletion { + LeadSigningCompletionView(model: model) + } else { + LeadSigningSignatureView( + signerName: model.request.signerName, drawing: $model.drawing, + isEnabled: model.sourceURL != nil && !model.isSigning + ) + .padding(.top, 16) + if let message = model.message { + Text(message).font(.callout).foregroundStyle(.red) + if model.sourceURL == nil { + Button("Erneut versuchen") { Task { await model.loadCurrentDocument() } } + .disabled(model.isLoading) + } + } + LeadSigningFooterView( + title: model.currentDocument?.title.replacingOccurrences(of: ".pdf", with: "") ?? "", + canSign: model.canSign, isSigning: model.isSigning, + isLast: model.isLastPendingDocument + ) { + Task { await model.signAndAdvance(onSigned: onSigned) } + } + } + } + } +} diff --git a/GasTankAR/Views/Leads/Signing/LeadSigningSignatureView.swift b/GasTankAR/Views/Leads/Signing/LeadSigningSignatureView.swift new file mode 100644 index 0000000..015b002 --- /dev/null +++ b/GasTankAR/Views/Leads/Signing/LeadSigningSignatureView.swift @@ -0,0 +1,65 @@ +import SwiftUI +import PencilKit + +#Preview("Unterschrift · Dark Mode", traits: .fixedLayout(width: 700, height: 240)) { + @Previewable @State var drawing = PKDrawing(strokes: [ + PKStroke( + ink: PKInk(.pen, color: .black), + path: PKStrokePath(controlPoints: [ + CGPoint(x: 100, y: 90), CGPoint(x: 150, y: 35), + CGPoint(x: 140, y: 95), CGPoint(x: 210, y: 55), + CGPoint(x: 240, y: 90), CGPoint(x: 320, y: 65) + ].enumerated().map { index, point in + PKStrokePoint(location: point, timeOffset: Double(index) * 0.1, + size: CGSize(width: 3, height: 3), opacity: 1, force: 1, + azimuth: 0, altitude: .pi / 2) + }, creationDate: .now) + ) + ]) + LeadSigningSignatureView(signerName: "Klaus Hoffmann", drawing: $drawing, isEnabled: true) + .padding(20) + .background(Color(uiColor: .systemGroupedBackground)) + .preferredColorScheme(.dark) +} + +struct LeadSigningSignatureView: View { + let signerName: String + @Binding var drawing: PKDrawing + let isEnabled: Bool + + var body: some View { + VStack(spacing: 8) { + HStack { + Text("UNTERSCHRIFT · \(signerName)") + .font(.caption).foregroundStyle(.secondary) + Spacer() + Button("Löschen") { drawing = PKDrawing() } + .disabled(drawing.strokes.isEmpty || !isEnabled) + } + .padding(.horizontal, 16) + ZStack { + VStack { + Spacer() + if drawing.strokes.isEmpty { + Label("Mit Finger oder Apple Pencil unterschreiben", systemImage: "signature") + .font(.subheadline).foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + Spacer() + HStack { + Text("×").foregroundStyle(.secondary) + Spacer() + } + Rectangle().fill(Color(uiColor: .separator)).frame(height: 1) + } + .padding(24) + .allowsHitTesting(false) + LeadSignatureCanvas(drawing: $drawing) + .allowsHitTesting(isEnabled) + } + .frame(height: 150) + .background(Color(uiColor: .secondarySystemGroupedBackground), in: .rect(cornerRadius: 12)) + .clipShape(.rect(cornerRadius: 12)) + } + } +} diff --git a/GasTankAR/Views/Leads/Signing/LeadSigningViewModel.swift b/GasTankAR/Views/Leads/Signing/LeadSigningViewModel.swift new file mode 100644 index 0000000..3b53e2c --- /dev/null +++ b/GasTankAR/Views/Leads/Signing/LeadSigningViewModel.swift @@ -0,0 +1,145 @@ +import Foundation +import Observation +import PDFKit +import PencilKit + +struct LeadSigningRequest: Identifiable { + let id = UUID() + let offerID: String + let signerName: String + let documents: [LeadDocument] +} + +@MainActor +@Observable +final class LeadSigningViewModel { + let request: LeadSigningRequest + let pendingDocuments: [LeadDocument] + private(set) var currentIndex = 0 + private(set) var sourceURL: URL? + private(set) var signedURLs: [String: URL] = [:] + private(set) var isLoading = false + private(set) var isSigning = false + private(set) var isComplete = false + private(set) var isUploading = false + private(set) var uploadResult: LeadPDFUploadResult? + private(set) var uploadError: String? + private(set) var message: String? + var drawing = PKDrawing() + private let repository: any LeadDetailRepository + private let signingService: any PDFSigningService + private let store: LeadSignedDocumentStore + private let uploadService: any LeadSignedPDFUploadService + private let leadID: String + private let salesCode: String + + init(request: LeadSigningRequest, leadID: String, salesCode: String, + repository: any LeadDetailRepository, + signingService: (any PDFSigningService)? = nil, store: LeadSignedDocumentStore? = nil, + uploadService: (any LeadSignedPDFUploadService)? = nil) { + self.request = request + self.pendingDocuments = request.documents.filter { $0.status == .pending } + self.leadID = leadID + self.salesCode = salesCode + self.repository = repository + self.signingService = signingService ?? DefaultPDFSigningService() + self.store = store ?? LeadSignedDocumentStore() + self.uploadService = uploadService ?? StubLeadSignedPDFUploadService() + } + + var currentDocumentNumber: Int { + guard let currentDocument, + let index = request.documents.firstIndex(where: { $0.id == currentDocument.id }) else { return 0 } + return index + 1 + } + + var isLastPendingDocument: Bool { currentIndex == pendingDocuments.count - 1 } + + var isDemoUpload: Bool { uploadService.isSimulation } + var isBusy: Bool { isSigning || isUploading } + var canFinish: Bool { isComplete && uploadResult != nil && !isBusy } + var showsCompletion: Bool { + isComplete || (isSigning && isLastPendingDocument) + } + + func uploadSignedDocuments() async { + guard isComplete, !isUploading, uploadResult == nil else { return } + isUploading = true + uploadError = nil + defer { isUploading = false } + do { + let files = pendingDocuments.compactMap { signedURLs[$0.id] } + guard files.count == pendingDocuments.count else { throw PDFSigningError.invalidDocument } + uploadResult = try await uploadService.upload( + files, leadID: leadID, offerID: request.offerID, salesCode: salesCode + ) + } catch is CancellationError { + uploadError = "Die Übertragung wurde unterbrochen. Die signierten PDFs bleiben gespeichert." + } catch { + uploadError = error.localizedDescription + } + } + + var currentDocument: LeadDocument? { + pendingDocuments.indices.contains(currentIndex) ? pendingDocuments[currentIndex] : nil + } + + var canSign: Bool { + sourceURL != nil && currentDocument?.signaturePlacement != nil + && !drawing.strokes.isEmpty && !isLoading && !isSigning && !isComplete + } + + func loadCurrentDocument() async { + guard let document = currentDocument else { return } + isLoading = true + sourceURL = nil + message = nil + defer { isLoading = false } + do { + let url = try await DownloadLeadDocumentUseCase(repository: repository) + .execute(document, leadID: leadID, salesCode: salesCode) + try Task.checkCancellation() + guard let pdf = PDFDocument(url: url), !pdf.isLocked else { + throw PDFSigningError.invalidDocument + } + guard let placement = document.signaturePlacement, + placement.pageIndex >= 0, + let page = pdf.page(at: placement.pageIndex), + page.bounds(for: .mediaBox).contains(placement.bounds) else { + throw PDFSigningError.invalidPlacement + } + sourceURL = url + } catch is CancellationError { + } catch { + message = error.localizedDescription + } + } + + func signAndAdvance(onSigned: (LeadDocument, URL) -> Void) async { + guard canSign, let document = currentDocument, + let sourceURL, let placement = document.signaturePlacement else { return } + isSigning = true + message = nil + defer { isSigning = false } + do { + let result = try await signingService.sign(source: sourceURL, drawing: drawing, placement: placement) + try store.save(url: result, document: document, + leadID: leadID, salesCode: salesCode) + signedURLs[document.id] = result + onSigned(document, result) + drawing = PKDrawing() + if currentIndex + 1 < pendingDocuments.count { + currentIndex += 1 + await loadCurrentDocument() + } else { + self.sourceURL = result + isComplete = true + await uploadSignedDocuments() + } + } catch is CancellationError { + } catch { + // Keep both the source and handwriting intact so saving can be retried. + message = error.localizedDescription + } + } +} diff --git a/GasTankAR/Views/LoginView.swift b/GasTankAR/Views/LoginView.swift new file mode 100644 index 0000000..7c18876 --- /dev/null +++ b/GasTankAR/Views/LoginView.swift @@ -0,0 +1,289 @@ +import SwiftUI + +private enum LoginStyle { + static let blue = Color(red: 0, green: 0.44, blue: 0.78) + static let background = Color(uiColor: .systemGroupedBackground) +} + +struct LoginView: View { + let onLogin: (LoginSession) -> Void + + @State private var model: LoginViewModel + @State private var isLoginRequested = false + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + + init(loginUseCase: any LoginUseCase, onLogin: @escaping (LoginSession) -> Void) { + _model = State(initialValue: LoginViewModel(loginUseCase: loginUseCase)) + self.onLogin = onLogin + } + + var body: some View { + GeometryReader { geometry in + let isWide = geometry.size.width >= 850 + && geometry.size.width > geometry.size.height + && !dynamicTypeSize.isAccessibilitySize + + ScrollView { + if isWide { + HStack(spacing: 0) { + LoginBrandPanel(isWide: true) + .frame(width: geometry.size.width * 0.54) + LoginFormPanel(model: model, isLoginRequested: $isLoginRequested, isWide: true) + .frame(maxWidth: .infinity) + } + .frame(minHeight: geometry.size.height, alignment: .top) + } else { + VStack(spacing: 0) { + LoginBrandPanel(isWide: false) + .fixedSize(horizontal: false, vertical: true) + LoginFormPanel(model: model, isLoginRequested: $isLoginRequested, isWide: false) + .fixedSize(horizontal: false, vertical: true) + } + .frame(minHeight: geometry.size.height, alignment: .top) + } + } + .scrollDismissesKeyboard(.interactively) + .background { + if isWide { + HStack(spacing: 0) { + LoginStyle.blue.frame(width: geometry.size.width * 0.54) + LoginStyle.background + } + .ignoresSafeArea() + } else { + LoginStyle.background.ignoresSafeArea() + } + } + } + .tint(LoginStyle.blue) + .task(id: isLoginRequested) { + guard isLoginRequested else { return } + defer { isLoginRequested = false } + if let session = await model.signIn(), !Task.isCancelled { + onLogin(session) + } + } + } +} + +private struct LoginBrandPanel: View { + let isWide: Bool + @ScaledMetric(relativeTo: .largeTitle) private var titleSize = 40 + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + if isWide { + Spacer(minLength: 60) + } + + VStack(alignment: .leading, spacing: 24) { + Image("LoginLogo") + .resizable() + .scaledToFit() + .frame(width: isWide ? 96 : 72, height: isWide ? 96 : 72) + .clipShape(.rect(cornerRadius: 23)) + .overlay { + RoundedRectangle(cornerRadius: 23) + .strokeBorder(.white.opacity(0.8), lineWidth: 2) + } + .shadow(color: .black.opacity(0.12), radius: 12, y: 6) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 8) { + Text("DrachenGas") + .font(.system(size: titleSize, weight: .bold)) + .lineLimit(1) + .minimumScaleFactor(0.5) + Text("Außendienst") + .font(.title3) + .foregroundStyle(.white.opacity(0.85)) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + if isWide { + Spacer(minLength: 60) + } + + Text("Drachen-Propangas GmbH · Wetzlar") + .font(.footnote) + .foregroundStyle(.white.opacity(0.75)) + .padding(.top, 32) + } + .padding(isWide ? 42 : 28) + .frame(maxWidth: .infinity, maxHeight: isWide ? .infinity : nil, alignment: .leading) + .background { + LoginStyle.blue.ignoresSafeArea(.container, edges: .top) + } + .foregroundStyle(.white) + } +} + +private struct LoginFormPanel: View { + @Bindable var model: LoginViewModel + @Binding var isLoginRequested: Bool + let isWide: Bool + @FocusState private var isCodeFocused: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Spacer(minLength: isWide ? 60 : 0) + + VStack(alignment: .leading, spacing: 24) { + VStack(alignment: .leading, spacing: 8) { + Text("Anmelden") + .font(.largeTitle.bold()) + Text("Mit deinem persönlichen Vertriebskürzel.") + .foregroundStyle(.secondary) + } + + VStack(alignment: .leading, spacing: 10) { + Text("KÜRZEL") + .font(.caption.weight(.medium)) + .tracking(1) + .foregroundStyle(.secondary) + + HStack(spacing: 14) { + Image(systemName: "person") + .foregroundStyle(LoginStyle.blue) + .accessibilityHidden(true) + TextField("z. B. MKR", text: $model.salesCode) + .font(.title3.bold()) + .tracking(2) + .textContentType(.username) + .textInputAutocapitalization(.characters) + .autocorrectionDisabled() + .submitLabel(.go) + .focused($isCodeFocused) + .accessibilityLabel("Vertriebskürzel") + .onSubmit(requestLogin) + } + .padding(18) + .background(Color(uiColor: .secondarySystemGroupedBackground), in: .rect(cornerRadius: 14)) + .overlay { + RoundedRectangle(cornerRadius: 14) + .strokeBorder( + isCodeFocused ? LoginStyle.blue : Color.primary.opacity(0.08), + lineWidth: 1 + ) + } + .disabled(model.isLoading || isLoginRequested) + + Text("Mindestens 3 Zeichen, z. B. MKR.") + .font(.footnote) + .foregroundStyle(.secondary) + } + + if let errorMessage = model.errorMessage { + Label { + Text(errorMessage) + } icon: { + Image(systemName: "exclamationmark.circle") + } + .font(.callout) + .foregroundStyle(.red) + .accessibilityIdentifier("loginError") + } + + Button(action: requestLogin) { + HStack(spacing: 10) { + if model.isLoading { + ProgressView().tint(.white) + } + Text(model.isLoading ? "Anmelden …" : "Anmelden") + .font(.headline) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .foregroundStyle(.white) + .background( + LoginStyle.blue.opacity(model.canSubmit ? 1 : 0.42), + in: .rect(cornerRadius: 14) + ) + } + .buttonStyle(.plain) + .disabled(!model.canSubmit || isLoginRequested) + } + + Spacer(minLength: 48) + LoginFooter() + } + .padding(isWide ? 48 : 28) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(LoginStyle.background) + } + + private func requestLogin() { + guard model.canSubmit, !isLoginRequested else { return } + isCodeFocused = false + isLoginRequested = true + } +} + +private struct LoginFooter: View { + @State private var isShowingContact = false + private let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "–" + + var body: some View { + ViewThatFits(in: .horizontal) { + HStack(alignment: .firstTextBaseline, spacing: 16) { + LoginContactButton(isShowingContact: $isShowingContact) + Spacer(minLength: 0) + Text("Version \(version)") + } + VStack(alignment: .leading, spacing: 16) { + LoginContactButton(isShowingContact: $isShowingContact) + Text("Version \(version)") + } + } + .font(.footnote) + .foregroundStyle(.secondary) + .alert("Vertriebsleitung kontaktieren", isPresented: $isShowingContact) { + Button("OK", role: .cancel) {} + } message: { + Text("Bitte wende dich über die bekannten Kontaktwege an deine Vertriebsleitung. Eine direkte Kontaktmöglichkeit wird hier später ergänzt.") + } + } +} + +private struct LoginContactButton: View { + @Binding var isShowingContact: Bool + + var body: some View { + ViewThatFits(in: .horizontal) { + HStack(spacing: 4) { + Text("Kürzel vergessen?") + contactButton + } + VStack(alignment: .leading, spacing: 4) { + Text("Kürzel vergessen?") + contactButton + } + } + } + + private var contactButton: some View { + Button("Vertriebsleitung kontaktieren") { + isShowingContact = true + } + .buttonStyle(.plain) + .foregroundStyle(LoginStyle.blue) + } +} + +#Preview("Breit", traits: .fixedLayout(width: 1194, height: 834)) { + LoginView(loginUseCase: DefaultLoginUseCase(authenticationService: StubAuthenticationService())) { _ in } +} + +#Preview("iPad Hochkant", traits: .fixedLayout(width: 834, height: 1194)) { + LoginView(loginUseCase: DefaultLoginUseCase(authenticationService: StubAuthenticationService())) { _ in } +} + +#Preview("Schmal", traits: .fixedLayout(width: 390, height: 844)) { + LoginView(loginUseCase: DefaultLoginUseCase(authenticationService: StubAuthenticationService())) { _ in } +} + +#Preview("Große Schrift", traits: .fixedLayout(width: 390, height: 844)) { + LoginView(loginUseCase: DefaultLoginUseCase(authenticationService: StubAuthenticationService())) { _ in } + .environment(\.dynamicTypeSize, .accessibility3) +} diff --git a/GasTankAR/Views/LoginViewModel.swift b/GasTankAR/Views/LoginViewModel.swift new file mode 100644 index 0000000..8cc4d5d --- /dev/null +++ b/GasTankAR/Views/LoginViewModel.swift @@ -0,0 +1,47 @@ +import Foundation +import Observation + +@MainActor +@Observable +final class LoginViewModel { + var salesCode = "" { + didSet { errorMessage = nil } + } + private(set) var isLoading = false + private(set) var errorMessage: LocalizedStringResource? + + @ObservationIgnored private let loginUseCase: any LoginUseCase + + init(loginUseCase: any LoginUseCase) { + self.loginUseCase = loginUseCase + } + + var canSubmit: Bool { + SalesCodeValidator.isValid(salesCode) && !isLoading + } + + func signIn() async -> LoginSession? { + guard !isLoading else { return nil } + errorMessage = nil + isLoading = true + defer { isLoading = false } + + do { + let session = try await loginUseCase.execute(salesCode: salesCode) + try Task.checkCancellation() + return session + } catch is CancellationError { + return nil + } catch { + switch error as? LoginError { + case .invalidSalesCode: + errorMessage = "Bitte gib ein Kürzel mit mindestens 3 Zeichen ein." + case .rejected: + errorMessage = "Das Kürzel wurde nicht erkannt. Bitte prüfe deine Eingabe." + default: + errorMessage = "Die Anmeldung ist derzeit nicht möglich. Bitte versuche es erneut." + } + return nil + } + } +} diff --git a/GasTankARTests/ARProjectsTests.swift b/GasTankARTests/ARProjectsTests.swift new file mode 100644 index 0000000..0e1bf2f --- /dev/null +++ b/GasTankARTests/ARProjectsTests.swift @@ -0,0 +1,187 @@ +import Foundation +import SwiftData +import Testing +@testable import GasTankAR + +@MainActor +struct ARProjectsTests { + private func detail(_ id: String = "L-240318") throws -> LeadDetail { + let lead = try #require(LeadSampleData.records.first { $0.id == id }).toDomain() + return LeadDetailSampleData.make(for: lead) + } + + private func container() throws -> ModelContainer { + try ModelContainer(for: Project.self, LeadARProjectLink.self, + configurations: ModelConfiguration(isStoredInMemoryOnly: true)) + } + + @Test func suppliedProjectsAreIdempotentAndExcludeServiceOffers() throws { + let container = try container() + let detail = try detail() + let service = LeadARProjectsService(detail: detail, container: container) + let first = try service.loadProjects() + let second = try LeadARProjectsService(detail: detail, container: container).loadProjects() + #expect(first.count == 2) + #expect(Set(first.map(\.id)) == Set(second.map(\.id))) + #expect(first.allSatisfy { $0.source == .supplied }) + #expect(try ModelContext(container).fetchCount(FetchDescriptor()) == 2) + #expect(try ModelContext(container).fetchCount(FetchDescriptor()) == 2) + } + + @Test func customProjectSurvivesReloadAndIsScopedToItsLead() throws { + let container = try container() + let detail = try detail() + let service = LeadARProjectsService(detail: detail, container: container) + let project = try service.createProject(name: " Hinter dem Haus ", tank: Tank()) + let reopened = LeadARProjectsService(detail: detail, container: container) + #expect(try reopened.loadProjects().contains { $0.id == project.id && $0.name == "Hinter dem Haus" }) + #expect(try reopened.resolveProject(id: project.id).tank == Tank()) + let otherDetail = try self.detail("L-240330") + let other = LeadARProjectsService(detail: otherDetail, container: container) + #expect(try !other.loadProjects().contains { $0.id == project.id }) + #expect(throws: LeadARProjectsService.ProjectError.self) { try other.resolveProject(id: project.id) } + } + + @Test func legacySessionIsLinkedWithoutLosingARData() throws { + let container = try container() + let detail = try detail() + let context = ModelContext(container) + let legacy = Project(leadID: detail.lead.id, customerName: detail.lead.displayName, + creationDate: .now, tank: Tank(), worldMapData: Data([1, 2, 3]), + tankTransform: [1, 2, 3], measurementsData: Data([4, 5])) + context.insert(legacy) + try context.save() + let service = LeadARProjectsService(detail: detail, container: container) + let projects = try service.loadProjects() + #expect(projects.contains { $0.id == legacy.projectID && $0.source == .supplied }) + let restored = try service.resolveProject(id: legacy.projectID) + #expect(restored.worldMapData == Data([1, 2, 3])) + #expect(restored.measurementsData == Data([4, 5])) + #expect(restored.tankTransform == [1, 2, 3]) + #expect(try context.fetchCount(FetchDescriptor()) == 2) + } + + @Test func invalidCreationDoesNotLeaveProjectsOrLinks() throws { + let container = try container() + let service = LeadARProjectsService(detail: try detail(), container: container) + #expect(throws: LeadARProjectsService.ProjectError.self) { + try service.createProject(name: " ", tank: Tank()) + } + #expect(throws: LeadARProjectsService.ProjectError.self) { + try service.createProject(name: "Invalid", tank: Tank(color: .white, placement: .belowGround)) + } + #expect(try ModelContext(container).fetchCount(FetchDescriptor()) == 0) + #expect(try ModelContext(container).fetchCount(FetchDescriptor()) == 0) + } + + @Test func deletingCustomProjectRemovesAssociationAndSurvivesReload() throws { + let container = try container() + let detail = try detail() + let service = LeadARProjectsService(detail: detail, container: container) + let project = try service.createProject(name: "Löschen", tank: Tank()) + try service.deleteProject(id: project.id) + let reloaded = LeadARProjectsService(detail: detail, container: container) + #expect(try !reloaded.loadProjects().contains { $0.id == project.id }) + #expect(try ModelContext(container).fetchCount(FetchDescriptor()) == 2) + #expect(try ModelContext(container).fetchCount(FetchDescriptor()) == 2) + } + + @Test func suppliedProjectsCannotBeDeleted() throws { + let container = try container() + let service = LeadARProjectsService(detail: try detail(), container: container) + let project = try #require(service.loadProjects().first) + #expect(throws: LeadARProjectsService.ProjectError.self) { + try service.deleteProject(id: project.id) + } + #expect(try service.loadProjects().contains { $0.id == project.id }) + } + + @Test func editingUpdatesExistingProjectAndPreservesSession() throws { + let container = try container() + let detail = try detail() + let service = LeadARProjectsService(detail: detail, container: container) + let original = try service.createProject(name: "Alt", tank: Tank()) + let stored = try service.resolveProject(id: original.id) + stored.worldMapData = Data([1, 2, 3]) + stored.tankTransform = [1, 2, 3] + try stored.modelContext?.save() + let tank = Tank(size: .t2_1, color: .olive, placement: .belowGround) + let updated = try service.updateProject(id: original.id, name: " Neu ", tank: tank) + #expect(updated.id == original.id) + #expect(updated.createdAt == original.createdAt) + let reopened = LeadARProjectsService(detail: detail, container: container) + let project = try reopened.resolveProject(id: original.id) + #expect(project.projectName == "Neu") + #expect(project.tank == tank) + #expect(project.worldMapData == Data([1, 2, 3])) + #expect(project.tankTransform == [1, 2, 3]) + #expect(try ModelContext(container).fetchCount(FetchDescriptor()) == 1) + } + + @Test func editingRejectsSuppliedForeignAndInvalidProjects() throws { + let container = try container() + let service = LeadARProjectsService(detail: try detail(), container: container) + let supplied = try #require(service.loadProjects().first) + #expect(throws: LeadARProjectsService.ProjectError.self) { + try service.updateProject(id: supplied.id, name: "Changed", tank: Tank()) + } + let own = try service.createProject(name: "Original", tank: Tank()) + #expect(throws: LeadARProjectsService.ProjectError.self) { + try service.updateProject(id: own.id, name: " ", tank: Tank()) + } + #expect(throws: LeadARProjectsService.ProjectError.self) { + try service.updateProject(id: own.id, name: "Invalid", tank: Tank(color: .white, placement: .belowGround)) + } + let other = LeadARProjectsService(detail: try detail("L-240330"), container: container) + #expect(throws: LeadARProjectsService.ProjectError.self) { + try other.updateProject(id: own.id, name: "Foreign", tank: Tank()) + } + #expect(try service.resolveProject(id: own.id).projectName == "Original") + } + + @Test func galleryLoadsNewARPhotosWithoutDuplicatesAndExcludesOtherLeads() throws { + let container = try container() + let detail = try detail() + let service = LeadARProjectsService(detail: detail, container: container) + let own = try service.createProject(name: "Garten", tank: Tank()) + let ownProject = try service.resolveProject(id: own.id) + let context = try #require(ownProject.modelContext) + let adapter = ProjectPersistenceAdapter(project: ownProject, modelContext: context) + adapter.savePhoto(Data([1]), date: Date(timeIntervalSince1970: 1), coordinate: nil) + let supplied = try #require(service.loadProjects().first { $0.source == .supplied }) + let suppliedProject = try service.resolveProject(id: supplied.id) + ProjectPersistenceAdapter(project: suppliedProject, modelContext: context) + .savePhoto(Data([2]), date: Date(timeIntervalSince1970: 2), coordinate: nil) + let other = LeadARProjectsService(detail: try self.detail("L-240330"), container: container) + let otherValue = try other.createProject(name: "Anderer Kunde", tank: Tank()) + let otherProject = try other.resolveProject(id: otherValue.id) + ProjectPersistenceAdapter(project: otherProject, modelContext: try #require(otherProject.modelContext)) + .savePhoto(Data([9]), date: .now, coordinate: nil) + let initial = try LeadARPhotoStore(container: container).photos(for: detail.lead.id) + #expect(initial.map(\.imageData) == [Data([2]), Data([1])]) + adapter.savePhoto(Data([3]), date: Date(timeIntervalSince1970: 3), coordinate: nil) + let refreshed = try LeadARPhotoStore(container: container).photos(for: detail.lead.id) + #expect(refreshed.map(\.imageData) == [Data([3]), Data([2]), Data([1])]) + try service.deleteProject(id: own.id) + #expect(try LeadARPhotoStore(container: container).photos(for: detail.lead.id).count == 1) + } + + @Test func associationPersistsWhenDiskStoreReopens() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("projects.store") + let detail = try detail() + let projectID: UUID + do { + let store = try ModelContainer(for: Project.self, LeadARProjectLink.self, + configurations: ModelConfiguration(url: url)) + projectID = try LeadARProjectsService(detail: detail, container: store) + .createProject(name: "Persistiert", tank: Tank()).id + } + let reopened = try ModelContainer(for: Project.self, LeadARProjectLink.self, + configurations: ModelConfiguration(url: url)) + let projects = try LeadARProjectsService(detail: detail, container: reopened).loadProjects() + #expect(projects.contains { $0.id == projectID && $0.name == "Persistiert" }) + } +} diff --git a/GasTankARTests/DocuframeTests.swift b/GasTankARTests/DocuframeTests.swift index 9dfb217..b4e024f 100644 --- a/GasTankARTests/DocuframeTests.swift +++ b/GasTankARTests/DocuframeTests.swift @@ -12,9 +12,10 @@ import SwiftData // MARK: - Test-Doubles -/// Konfigurierbarer Mock des ``DocuframeService`` (Actor → threadsicher & Sendable), +/// Konfigurierbarer Mock mit derselben MainActor-Isolation wie ``DocuframeService``, /// der Aufrufe zählt und wahlweise einen Fehler wirft. -private actor MockDocuframeService: DocuframeService { +@MainActor +private final class MockDocuframeService: DocuframeService { enum Behavior: Sendable { case success case failure(DocuframeError) diff --git a/GasTankARTests/LeadDetailTests.swift b/GasTankARTests/LeadDetailTests.swift new file mode 100644 index 0000000..d2f1e89 --- /dev/null +++ b/GasTankARTests/LeadDetailTests.swift @@ -0,0 +1,146 @@ +import Foundation +import Testing +import PDFKit +@testable import GasTankAR + +@MainActor +struct LeadDetailDownloadTests { + private func fixture() async throws -> (DefaultLeadDetailRepository, StubLeadDetailRemoteService, LeadDetail) { + let service = StubLeadDetailRemoteService(delay: .zero) + let repository = DefaultLeadDetailRepository( + remoteService: service, + cacheDirectory: FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + ) + let detail = try await repository.detail(id: "L-240318", salesCode: "MKR") + return (repository, service, detail) + } + + @Test + func metadataDoesNotDownloadPDFsAndProgressMatchesDocuments() async throws { + let (_, service, detail) = try await fixture() + #expect(service.downloadCount == 0) + #expect(detail.offers.count == 3) + #expect(detail.offers[0].documents.count == 6) + #expect(detail.offers[0].signedCount == 2) + #expect(detail.offers[1].documents.count == 5) + #expect(detail.offers[2].isComplete) + #expect(detail.photos.count == 10) + } + + @Test + func downloadsActualPDFAndReusesCachedFile() async throws { + let (repository, service, detail) = try await fixture() + let document = try #require(detail.offers.first?.documents.first) + let useCase = DownloadLeadDocumentUseCase(repository: repository) + let first = try await useCase.execute(document, leadID: detail.lead.id, salesCode: "MKR") + let pdf = try #require(PDFDocument(url: first)) + #expect(pdf.pageCount == document.pageCount) + #expect(pdf.string?.contains("Beispieldokument") == true) + let second = try await useCase.execute(document, leadID: detail.lead.id, salesCode: "MKR") + #expect(first == second) + #expect(service.downloadCount == 1) + try FileManager.default.removeItem(at: first) + let third = try await useCase.execute(document, leadID: detail.lead.id, salesCode: "MKR") + #expect(FileManager.default.fileExists(atPath: third.path)) + #expect(service.downloadCount == 2) + } + + @Test + func concurrentDownloadsShareOneRequest() async throws { + let (repository, service, detail) = try await fixture() + service.delay = .milliseconds(50) + let file = try #require(detail.offers.first?.documents.first?.file) + let first = Task { try await repository.file(file, leadID: detail.lead.id, salesCode: "MKR") } + let second = Task { try await repository.file(file, leadID: detail.lead.id, salesCode: "MKR") } + let firstURL = try await first.value + let secondURL = try await second.value + #expect(firstURL == secondURL) + #expect(service.downloadCount == 1) + } + + @Test + func cacheIsScopedToUserAndDocumentVersion() async throws { + let (repository, service, detail) = try await fixture() + let file = try #require(detail.offers.first?.documents.first?.file) + let first = try await repository.file(file, leadID: detail.lead.id, salesCode: "MKR") + let otherUser = try await repository.file(file, leadID: detail.lead.id, salesCode: "ABC") + #expect(first != otherUser) + let nextVersion = LeadFileReference(id: file.id, version: "2", kind: .pdf) + await #expect(throws: LeadFileError.notFound) { + try await repository.file(nextVersion, leadID: detail.lead.id, salesCode: "MKR") + } + #expect(service.downloadCount == 3) + } + + @Test + func invalidPDFIsNotCachedAndRetrySucceeds() async throws { + let (repository, service, detail) = try await fixture() + let file = try #require(detail.offers.first?.documents.first?.file) + service.fileScenario = .invalidFile + await #expect(throws: LeadFileError.invalidFile) { + try await repository.file(file, leadID: detail.lead.id, salesCode: "MKR") + } + service.fileScenario = .success + let url = try await repository.file(file, leadID: detail.lead.id, salesCode: "MKR") + #expect(PDFDocument(url: url) != nil) + #expect(service.downloadCount == 2) + } + + @Test + func fileErrorsPreserveLoadedDetailAndMapCorrectly() async throws { + let (repository, service, detail) = try await fixture() + let file = try #require(detail.offers.first?.documents.first?.file) + for (scenario, expected): (StubLeadDetailRemoteService.Scenario, LeadError) in [ + (.offline, .offline), (.timeout, .timeout), (.http(500), .serviceUnavailable), + (.http(401), .unauthorized), (.http(403), .forbidden) + ] { + service.fileScenario = scenario + await #expect(throws: expected) { + try await repository.file(file, leadID: detail.lead.id, salesCode: "MKR") + } + } + service.fileScenario = .http(404) + await #expect(throws: LeadFileError.notFound) { + try await repository.file(file, leadID: detail.lead.id, salesCode: "MKR") + } + #expect(detail.lead.id == "L-240318") + } + + @Test + func treeExpandsCollapsesAndKeepsStateWhenSelectingPDF() async throws { + let (repository, _, _) = try await fixture() + let model = LeadDetailViewModel(leadID: "L-240318", salesCode: "MKR", repository: repository) + await model.load() + let detail = try #require(model.detail) + #expect(model.expandedOffers == [detail.offers[0].id]) + model.toggleAll() + #expect(model.isAllExpanded) + model.selectedDocument = detail.offers[0].documents[0] + model.selectedDocument = nil + #expect(model.isAllExpanded) + model.toggleAll() + #expect(model.expandedOffers.isEmpty) + #expect(model.expandedGroups.isEmpty) + model.toggleOffer(detail.offers[1].id) + #expect(model.expandedOffers == [detail.offers[1].id]) + } + + @Test + func anotherLeadKeepsItsOwnIdentityAndEmptyOffers() async throws { + let (repository, _, _) = try await fixture() + let detail = try await repository.detail(id: "L-240326", salesCode: "MKR") + #expect(detail.lead.companyName == "Schreinerei Vogt") + #expect(detail.offers.isEmpty) + #expect(detail.email == nil) + } + + @Test + func photoDownloadProducesAnImage() async throws { + let (repository, service, detail) = try await fixture() + let photo = try #require(detail.photos.first) + let url = try await LoadLeadPhotoUseCase(repository: repository) + .execute(photo, leadID: detail.lead.id, salesCode: "MKR") + #expect(FileManager.default.fileExists(atPath: url.path)) + #expect(service.downloadCount == 1) + } +} diff --git a/GasTankARTests/LeadTests.swift b/GasTankARTests/LeadTests.swift new file mode 100644 index 0000000..bbe8f29 --- /dev/null +++ b/GasTankARTests/LeadTests.swift @@ -0,0 +1,434 @@ +import Foundation +import Testing +@testable import GasTankAR + +@MainActor +struct LeadRepositoryTests { + @Test + func downloadsTenLeadsAndSortsNewestFirst() async throws { + let repository = DefaultLeadRepository(remoteService: StubLeadRemoteService(delay: .zero)) + let leads = try await LoadLeadsUseCase(repository: repository).execute(salesCode: "MKR") + #expect(leads.count == 10) + #expect(leads.first?.id == "L-240330") + #expect(leads.filter(\.isBookmarked).count == 3) + } + + @Test(arguments: ["hoffmann", " SABINE ", "kirchzarten", "l-240330"]) + func searchesCompanyPersonCityAndID(_ term: String) async throws { + let repository = DefaultLeadRepository(remoteService: StubLeadRemoteService(delay: .zero)) + let leads = try await SearchLeadsUseCase(repository: repository).execute( + salesCode: "MKR", query: LeadQuery(searchText: term) + ) + #expect(leads.count == 1) + } + + @Test + func filtersOpenContractsAndSortsNames() async throws { + let repository = DefaultLeadRepository(remoteService: StubLeadRemoteService(delay: .zero)) + let leads = try await repository.leads(for: "MKR", query: LeadQuery(sort: .nameAscending, filter: .unsignedContracts)) + #expect(!leads.isEmpty) + #expect(leads.allSatisfy { $0.unsignedContractCount > 0 }) + #expect(leads.first?.companyName == "Autohaus Kessler KG") + } + + @Test + func noMatchesIsAnEmptySuccess() async throws { + let repository = DefaultLeadRepository(remoteService: StubLeadRemoteService(delay: .zero)) + let leads = try await repository.leads(for: "MKR", query: LeadQuery(searchText: "nichtvorhanden")) + #expect(leads.isEmpty) + } + + @Test + func bookmarkIsScopedToEmployee() async throws { + let repository = DefaultLeadRepository(remoteService: StubLeadRemoteService(delay: .zero)) + let updated = try await repository.setBookmark(false, leadID: "L-240318", salesCode: "MKR") + let otherUser = try await repository.lead(id: "L-240318", salesCode: "ABC") + let sameUser = try await repository.lead(id: "L-240318", salesCode: "MKR") + #expect(!updated.isBookmarked) + #expect(!sameUser.isBookmarked) + #expect(otherUser.isBookmarked) + } + + @Test + func missingLeadDiffersFromMissingListEndpoint() async { + let service = StubLeadRemoteService(delay: .zero) + let repository = DefaultLeadRepository(remoteService: service) + await #expect(throws: LeadError.notFound(leadID: "missing")) { + try await repository.lead(id: "missing", salesCode: "MKR") + } + await #expect(throws: LeadError.notFound(leadID: "missing")) { + try await repository.setBookmark(true, leadID: "missing", salesCode: "MKR") + } + service.scenario = .http(404) + await #expect(throws: LeadError.serviceUnavailable) { + try await repository.leads(for: "MKR", query: LeadQuery()) + } + } + + @Test(arguments: [500, 502, 503]) + func mapsServerFailures(_ status: Int) async { + let repository = DefaultLeadRepository(remoteService: StubLeadRemoteService(scenario: .http(status), delay: .zero)) + await #expect(throws: LeadError.serviceUnavailable) { + try await repository.leads(for: "MKR", query: LeadQuery()) + } + } + + @Test + func mapsOfflineTimeoutAuthenticationAndMalformedResponses() async { + let cases: [(StubLeadRemoteService.Scenario, LeadError)] = [ + (.offline, .offline), (.timeout, .timeout), (.http(401), .unauthorized), + (.http(403), .forbidden), (.malformedResponse, .invalidResponse) + ] + for (scenario, expected) in cases { + let repository = DefaultLeadRepository(remoteService: StubLeadRemoteService(scenario: scenario, delay: .zero)) + await #expect(throws: expected) { + try await repository.leads(for: "MKR", query: LeadQuery()) + } + } + } + + @Test + func rejectsDuplicateServerIDs() async throws { + let record = try #require(LeadSampleData.records.first) + let repository = DefaultLeadRepository(remoteService: StubLeadRemoteService(delay: .zero, records: [record, record])) + await #expect(throws: LeadError.invalidResponse) { + try await repository.leads(for: "MKR", query: LeadQuery()) + } + } + + @Test + func rejectsInvalidDomainData() { + let invalid = LeadDTO(id: "ID", companyName: " ", contactTitle: nil, firstName: nil, + lastName: nil, street: nil, postalCode: "12345", city: "Ort", + phoneNumber: nil, createdAt: .now, offerCount: -1, + unsignedContractCount: 0, isBookmarked: false) + #expect(throws: LeadError.invalidResponse) { try invalid.toDomain() } + } +} + +@MainActor +struct LeadOverviewViewModelTests { + @Test + func clearingSearchRestoresCachedListWithoutRequest() async throws { + let repository = ControlledLeadRepository() + let model = LeadOverviewViewModel(salesCode: "MKR", repository: repository) + let records = try LeadSampleData.records.map { try $0.toDomain() } + let initial = Task { await model.reload() } + await repository.waitForRequests(1) + repository.finish(0, with: records) + await initial.value + let originalLeads = model.leads + let timestamp = model.lastUpdated + + model.searchText = "Hoffmann" + let search = Task { await model.reload() } + await repository.waitForRequests(2) + repository.finish(1, with: Array(records.prefix(1))) + await search.value + #expect(model.leads.count == 1) + + model.clearSearchText() + model.searchText = "" + #expect(model.query == LeadQuery()) + #expect(model.leads == originalLeads) + #expect(model.hasCurrentResults) + #expect(!model.isLoading) + #expect(model.lastUpdated == timestamp) + await model.reload(debounce: true, useCachedList: true) + #expect(repository.requestCount == 2) + + // Explicit refresh must still fetch fresh data. + let refresh = Task { await model.reload() } + await repository.waitForRequests(3) + repository.finish(2, with: []) + await refresh.value + #expect(model.leads.isEmpty) + } + + @Test + func clearingSearchRejectsLateResponseAndKeepsBookmarkChanges() async throws { + let repository = ControlledLeadRepository() + let model = LeadOverviewViewModel(salesCode: "MKR", repository: repository) + let records = try LeadSampleData.records.map { try $0.toDomain() } + let initial = Task { await model.reload() } + await repository.waitForRequests(1) + repository.finish(0, with: records) + await initial.value + + model.searchText = "Hoffmann" + let search = Task { await model.reload() } + await repository.waitForRequests(2) + var updated = try #require(records.first) + updated.isBookmarked.toggle() + model.applyBookmark(updated) + model.clearSearchText() + #expect(model.leads.count == records.count) + #expect(model.leads.first(where: { $0.id == updated.id }) == updated) + + repository.finish(1, with: []) + await search.value + #expect(model.leads.count == records.count) + #expect(model.hasCurrentResults) + #expect(!model.isLoading) + #expect(model.error == nil) + } + + @Test + func bookmarkConfirmationWaitsForAnimationCommitWithoutReloading() async throws { + let model = LeadOverviewViewModel(salesCode: "MKR", repository: DefaultLeadRepository( + remoteService: StubLeadRemoteService(delay: .zero) + )) + await model.reload() + let lead = try #require(model.bookmarkedLeads.first) + let timestamp = model.lastUpdated + let confirmed = try #require(await model.saveBookmark(lead)) + #expect(model.bookmarkedLeads.contains(lead)) + #expect(!model.isLoading) + model.applyBookmark(confirmed) + #expect(!model.bookmarkedLeads.contains(where: { $0.id == lead.id })) + #expect(model.lastUpdated == timestamp) + } + + @Test + func sortingAndFilteringReuseLoadedResults() async throws { + let repository = ControlledLeadRepository() + let model = LeadOverviewViewModel(salesCode: "MKR", repository: repository) + let request = Task { await model.reload() } + await repository.waitForRequests(1) + let records = try LeadSampleData.records.map { try $0.toDomain() } + repository.finish(0, with: records) + await request.value + let timestamp = model.lastUpdated + let query = model.query + + model.selection = .alphabetical + #expect(model.leads.first?.displayName == "Anna Schneider") + #expect(model.hasCurrentResults) + #expect(model.query == query) + #expect(!model.isLoading) + + model.selection = .open + #expect(model.leads.count == 6) + #expect(model.leads.allSatisfy { $0.unsignedContractCount > 0 }) + #expect(model.hasCurrentResults) + #expect(model.query == query) + #expect(!model.isLoading) + + model.selection = .newest + #expect(model.leads.count == 10) + #expect(model.leads.first?.id == "L-240330") + #expect(model.bookmarkedLeads.count == 3) + #expect(model.lastUpdated == timestamp) + #expect(repository.requestCount == 1) + } + + + @Test + func refreshDuringExistingLoadFetchesAgainAndKeepsNewestResponse() async throws { + let repository = ControlledLeadRepository() + let model = LeadOverviewViewModel(salesCode: "MKR", repository: repository) + let initial = Task { await model.reload() } + await repository.waitForRequests(1) + + let refresh = Task { await model.reload() } + await repository.waitForRequests(2) + let expected = try #require(LeadSampleData.records.first).toDomain() + repository.finish(1, with: [expected]) + await refresh.value + let refreshedAt = try #require(model.lastUpdated) + + repository.finish(0, with: []) + await initial.value + #expect(repository.requestCount == 2) + #expect(model.leads == [expected]) + #expect(model.lastUpdated == refreshedAt) + #expect(model.error == nil) + #expect(!model.isLoading) + } + + @Test + func refreshFailureKeepsDataAndSuccessfulTimestamp() async { + let service = StubLeadRemoteService(delay: .zero) + let model = LeadOverviewViewModel(salesCode: "MKR", repository: DefaultLeadRepository(remoteService: service)) + await model.reload() + let leads = model.leads + let timestamp = model.lastUpdated + service.scenario = .http(500) + await model.reload() + #expect(model.leads == leads) + #expect(model.lastUpdated == timestamp) + #expect(model.hasCurrentResults) + #expect(model.error == .serviceUnavailable) + #expect(!model.isLoading) + + service.scenario = .success + await model.reload() + #expect(model.error == nil) + } + + @Test + func failedSearchDoesNotPresentOldCardsAsNewResults() async { + let service = StubLeadRemoteService(delay: .zero) + let model = LeadOverviewViewModel(salesCode: "MKR", repository: DefaultLeadRepository(remoteService: service)) + await model.reload() + model.searchText = "Hoffmann" + #expect(!model.hasCurrentResults) + service.scenario = .timeout + await model.reload() + #expect(!model.hasCurrentResults) + #expect(model.searchText == "Hoffmann") + #expect(model.error == .timeout) + #expect(!model.isLoading) + } + + @Test + func unsuccessfulBookmarkPreservesState() async throws { + let service = StubLeadRemoteService(delay: .zero) + let model = LeadOverviewViewModel(salesCode: "MKR", repository: DefaultLeadRepository(remoteService: service)) + await model.reload() + let lead = try #require(model.bookmarkedLeads.first) + service.scenario = .http(500) + await model.toggleBookmark(lead) + #expect(model.bookmarkedLeads.contains(lead)) + #expect(model.actionError == .serviceUnavailable) + #expect(model.bookmarkingIDs.isEmpty) + } + + @Test + func bookmarkSuccessUpdatesBothSections() async throws { + let model = LeadOverviewViewModel(salesCode: "MKR", repository: DefaultLeadRepository(remoteService: StubLeadRemoteService(delay: .zero))) + await model.reload() + let lead = try #require(model.bookmarkedLeads.first) + await model.toggleBookmark(lead) + #expect(!model.bookmarkedLeads.contains(where: { $0.id == lead.id })) + #expect(model.leads.first(where: { $0.id == lead.id })?.isBookmarked == false) + } + + + @Test + func expiredSessionIsExposedForLoginAction() async { + let model = LeadOverviewViewModel(salesCode: "MKR", repository: DefaultLeadRepository( + remoteService: StubLeadRemoteService(scenario: .http(401), delay: .zero) + )) + await model.reload() + #expect(model.isSessionExpired) + #expect(!model.isLoading) + } + + @Test + func lateSearchResponseCannotOverwriteNewerResults() async throws { + let repository = ControlledLeadRepository() + let model = LeadOverviewViewModel(salesCode: "MKR", repository: repository) + model.searchText = "old" + let oldRequest = Task { await model.reload() } + await repository.waitForRequests(1) + model.searchText = "new" + let newRequest = Task { await model.reload() } + await repository.waitForRequests(2) + let expected = try #require(LeadSampleData.records.first).toDomain() + repository.finish(1, with: [expected]) + await newRequest.value + repository.finish(0, with: []) + await oldRequest.value + #expect(model.leads == [expected]) + #expect(model.hasCurrentResults) + #expect(model.error == nil) + #expect(!model.isLoading) + } + + @Test + func cancellationIsNotDisplayedAsFailure() async { + let repository = ControlledLeadRepository() + let model = LeadOverviewViewModel(salesCode: "MKR", repository: repository) + let task = Task { await model.reload() } + await repository.waitForRequests(1) + task.cancel() + repository.finish(0, with: []) + await task.value + #expect(model.error == nil) + #expect(model.lastUpdated == nil) + #expect(!model.isLoading) + } +} + +@MainActor +private final class ControlledLeadRepository: LeadRepository { + private var requests: [CheckedContinuation<[Lead], Never>] = [] + var requestCount: Int { requests.count } + private var observers: [(Int, CheckedContinuation)] = [] + + func leads(for salesCode: String, query: LeadQuery) async throws -> [Lead] { + await withCheckedContinuation { continuation in + requests.append(continuation) + let ready = observers.filter { $0.0 <= requests.count } + observers.removeAll { $0.0 <= requests.count } + for observer in ready { observer.1.resume() } + } + } + + func waitForRequests(_ count: Int) async { + if requests.count >= count { return } + await withCheckedContinuation { observers.append((count, $0)) } + } + + func finish(_ index: Int, with leads: [Lead]) { + requests[index].resume(returning: leads) + } + + func lead(id: String, salesCode: String) async throws -> Lead { + throw LeadError.notFound(leadID: id) + } + + func setBookmark(_ bookmarked: Bool, leadID: String, salesCode: String) async throws -> Lead { + throw LeadError.notFound(leadID: leadID) + } +} + +@MainActor +struct LeadDetailViewModelTests { + @Test + func startsWithPlaceholdersThenLoadsSelectedLead() async { + let model = LeadDetailViewModel(leadID: "L-240330", salesCode: "MKR", + repository: DefaultLeadDetailRepository(remoteService: StubLeadDetailRemoteService(delay: .zero))) + #expect(model.lead == nil) + #expect(model.error == nil) + await model.load() + #expect(model.lead?.id == "L-240330") + #expect(model.error == nil) + } + + @Test + func missingLeadShowsDetailError() async { + let model = LeadDetailViewModel(leadID: "missing", salesCode: "MKR", + repository: DefaultLeadDetailRepository(remoteService: StubLeadDetailRemoteService(delay: .zero))) + await model.load() + #expect(model.lead == nil) + #expect(model.error == .notFound(leadID: "missing")) + } + + @Test + func retryRecoversFromTimeout() async { + let service = StubLeadDetailRemoteService(delay: .zero) + service.detailScenario = .timeout + let model = LeadDetailViewModel(leadID: "L-240330", salesCode: "MKR", + repository: DefaultLeadDetailRepository(remoteService: service)) + await model.load() + #expect(model.lead == nil) + #expect(model.error == .timeout) + service.detailScenario = .success + await model.load() + #expect(model.lead?.id == "L-240330") + #expect(model.error == nil) + } + + @Test + func cancelledLoadDoesNotPublishDataOrError() async { + let model = LeadDetailViewModel(leadID: "L-240330", salesCode: "MKR", + repository: DefaultLeadDetailRepository(remoteService: StubLeadDetailRemoteService(delay: .zero))) + let task = Task { await model.load() } + task.cancel() + await task.value + #expect(model.lead == nil) + #expect(model.error == nil) + } +} diff --git a/GasTankARTests/LoginTests.swift b/GasTankARTests/LoginTests.swift new file mode 100644 index 0000000..91d6eec --- /dev/null +++ b/GasTankARTests/LoginTests.swift @@ -0,0 +1,137 @@ +import Foundation +import Testing +@testable import GasTankAR + +@MainActor +struct LoginTests { + @Test(arguments: ["", " ", "AB", " AB\n"]) + func invalidCodeDoesNotReachService(_ code: String) async { + let service = RecordingAuthenticationService() + let useCase = DefaultLoginUseCase(authenticationService: service) + + await #expect(throws: LoginError.invalidSalesCode) { + try await useCase.execute(salesCode: code) + } + #expect(service.receivedCodes.isEmpty) + } + + @Test + func normalizesCodeBeforeAuthentication() async throws { + let service = RecordingAuthenticationService() + let useCase = DefaultLoginUseCase(authenticationService: service) + + let session = try await useCase.execute(salesCode: " mkr\n") + + #expect(service.receivedCodes == ["MKR"]) + #expect(session.salesCode == "MKR") + } + + @Test + func serviceFailurePropagates() async { + let useCase = DefaultLoginUseCase( + authenticationService: StubAuthenticationService(simulatedError: .rejected, delay: .zero) + ) + await #expect(throws: LoginError.rejected) { + try await useCase.execute(salesCode: "MKR") + } + } + + @Test + func successfulLoginReturnsSessionAndResetsLoading() async { + let model = LoginViewModel(loginUseCase: StubLoginUseCase(error: nil)) + model.salesCode = "MKR" + + let session = await model.signIn() + + #expect(session == LoginSession(salesCode: "MKR")) + #expect(!model.isLoading) + #expect(model.errorMessage == nil) + } + + @Test + func failureIsDisplayedAndEditingClearsIt() async { + let model = LoginViewModel(loginUseCase: StubLoginUseCase(error: .rejected)) + model.salesCode = "MKR" + + let session = await model.signIn() + + #expect(session == nil) + #expect(model.errorMessage != nil) + #expect(!model.isLoading) + model.salesCode = "ABC" + #expect(model.errorMessage == nil) + #expect(model.canSubmit) + } + + @Test + func concurrentSubmissionIsIgnored() async { + let useCase = SuspendedLoginUseCase() + let model = LoginViewModel(loginUseCase: useCase) + model.salesCode = "MKR" + let first = Task { await model.signIn() } + // Wait for a deterministic suspension point instead of a timed delay. + while useCase.continuation == nil { + await Task.yield() + } + + #expect(model.isLoading) + #expect(!model.canSubmit) + let duplicate = await model.signIn() + #expect(duplicate == nil) + #expect(useCase.callCount == 1) + + useCase.continuation?.resume(returning: LoginSession(salesCode: "MKR")) + let result = await first.value + #expect(result != nil) + #expect(!model.isLoading) + } + + @Test + func cancelledLoginDoesNotReturnSessionOrShowError() async { + let useCase = SuspendedLoginUseCase() + let model = LoginViewModel(loginUseCase: useCase) + model.salesCode = "MKR" + let task = Task { await model.signIn() } + while useCase.continuation == nil { + await Task.yield() + } + + task.cancel() + useCase.continuation?.resume(returning: LoginSession(salesCode: "MKR")) + let result = await task.value + + #expect(result == nil) + #expect(model.errorMessage == nil) + #expect(!model.isLoading) + } +} + +@MainActor +private final class RecordingAuthenticationService: AuthenticationService { + private(set) var receivedCodes: [String] = [] + + func signIn(salesCode: String) async throws -> LoginSession { + receivedCodes.append(salesCode) + return LoginSession(salesCode: salesCode) + } +} + +private struct StubLoginUseCase: LoginUseCase { + let error: LoginError? + + func execute(salesCode: String) async throws -> LoginSession { + if let error { throw error } + return LoginSession(salesCode: salesCode) + } +} + +@MainActor +private final class SuspendedLoginUseCase: LoginUseCase { + var continuation: CheckedContinuation? + private(set) var callCount = 0 + + func execute(salesCode: String) async throws -> LoginSession { + callCount += 1 + return await withCheckedContinuation { continuation = $0 } + } +} diff --git a/GasTankARTests/PDFSigningTests.swift b/GasTankARTests/PDFSigningTests.swift new file mode 100644 index 0000000..febca62 --- /dev/null +++ b/GasTankARTests/PDFSigningTests.swift @@ -0,0 +1,266 @@ +import Testing +import Foundation +import PDFKit +import PencilKit +import UIKit +@testable import GasTankAR + +@MainActor +struct PDFSigningTests { + private func handwriting() -> PKDrawing { + let locations = [CGPoint(x: 20, y: 20), CGPoint(x: 50, y: 65), CGPoint(x: 100, y: 25)] + let points = locations.enumerated().map { index, location in + PKStrokePoint(location: location, timeOffset: Double(index) * 0.1, + size: CGSize(width: 2, height: 2), opacity: 1, force: 1, + azimuth: 0, altitude: .pi / 2) + } + return PKDrawing(strokes: [ + PKStroke(ink: PKInk(.pen, color: .black), + path: PKStrokePath(controlPoints: points, creationDate: Date())) + ]) + } + + private func directory() throws -> URL { + let url = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private func source(in directory: URL) throws -> URL { + let renderer = UIGraphicsPDFRenderer(bounds: CGRect(x: 0, y: 0, width: 595, height: 842)) + let data = renderer.pdfData { context in + for number in 1...2 { + context.beginPage() + ("Original page \(number)" as NSString).draw( + at: CGPoint(x: 40, y: 40), withAttributes: [.font: UIFont.systemFont(ofSize: 16)] + ) + } + } + let url = directory.appending(path: "original.pdf") + try data.write(to: url) + return url + } + + @Test + func signatureSurvivesReopeningAndPreservesOriginalPages() async throws { + let folder = try directory() + defer { try? FileManager.default.removeItem(at: folder) } + let original = try source(in: folder) + let originalData = try Data(contentsOf: original) + let bounds = CGRect(x: 310, y: 76, width: 237, height: 60) + let url = try await DefaultPDFSigningService(outputDirectory: folder).sign( + source: original, drawing: handwriting(), + placement: PDFSignaturePlacement(pageIndex: 1, bounds: bounds) + ) + let result = try #require(PDFDocument(url: url)) + #expect(result.pageCount == 2) + #expect(result.page(at: 0)?.string?.contains("Original page 1") == true) + #expect(result.page(at: 1)?.string?.contains("Original page 2") == true) + #expect(result.page(at: 0)?.annotations.isEmpty == true) + let annotation = try #require(result.page(at: 1)?.annotations.first) + #expect(annotation.type == "Ink") + #expect(annotation.bounds == bounds) + let paths = try #require(annotation.paths) + #expect(!paths.isEmpty) + for path in paths { + #expect(bounds.contains(path.bounds)) + } + #expect(try Data(contentsOf: original) == originalData) + #expect(url != original) + } + + @Test + func rejectsEmptyDrawingAndOutOfPagePlacement() async throws { + let folder = try directory() + defer { try? FileManager.default.removeItem(at: folder) } + let original = try source(in: folder) + let service = DefaultPDFSigningService(outputDirectory: folder) + await #expect(throws: PDFSigningError.self) { + try await service.sign(source: original, drawing: PKDrawing(), + placement: PDFSignaturePlacement(pageIndex: 0, bounds: .zero)) + } + await #expect(throws: PDFSigningError.self) { + try await service.sign(source: original, drawing: handwriting(), + placement: PDFSignaturePlacement(pageIndex: 8, + bounds: CGRect(x: 0, y: 0, width: 200, height: 50))) + } + await #expect(throws: PDFSigningError.self) { + try await service.sign(source: original, drawing: handwriting(), + placement: PDFSignaturePlacement(pageIndex: 0, + bounds: CGRect(x: 590, y: 0, width: 200, height: 50))) + } + } + + @Test + func failedSigningKeepsHandwritingAndCurrentDocument() async throws { + let folder = try directory() + defer { try? FileManager.default.removeItem(at: folder) } + let repository = DefaultLeadDetailRepository(remoteService: StubLeadDetailRemoteService(delay: .zero)) + let detail = try await repository.detail(id: "L-240318", salesCode: "MKR") + let offer = try #require(detail.offers.first) + let documents = offer.documents.filter { $0.status == .pending } + let service = FailingSigningService() + let model = LeadSigningViewModel( + request: LeadSigningRequest(offerID: offer.id, signerName: "Klaus Hoffmann", documents: documents), + leadID: detail.lead.id, salesCode: "MKR", repository: repository, + signingService: service, store: LeadSignedDocumentStore(directory: folder) + ) + await model.loadCurrentDocument() + model.drawing = handwriting() + let input = model.drawing + var callbackCount = 0 + await model.signAndAdvance { _, _ in callbackCount += 1 } + #expect(service.calls == 1) + #expect(callbackCount == 0) + #expect(model.currentIndex == 0) + #expect(model.drawing == input) + #expect(model.sourceURL != nil) + #expect(model.message != nil) + #expect(model.canSign) + } + + @Test + func listsAllDocumentsButSignsAndUploadsOnlyPendingDocuments() async throws { + let folder = try directory() + defer { try? FileManager.default.removeItem(at: folder) } + let repository = DefaultLeadDetailRepository(remoteService: StubLeadDetailRemoteService(delay: .zero)) + let detail = try await repository.detail(id: "L-240318", salesCode: "MKR") + var offer = try #require(detail.offers.first) + var documents = Array(offer.documents.prefix(5)) + for index in [0, 2, 4] { documents[index].status = .signed } + offer.groups = [LeadDocumentGroup(id: "mixed", title: "Dokumente", documents: documents)] + let detailModel = LeadDetailViewModel(leadID: detail.lead.id, salesCode: "MKR", repository: repository) + detailModel.startSigning(offer: offer) + let request = try #require(detailModel.signingRequest) + #expect(request.documents == documents) + detailModel.startSigning(offer: offer, group: offer.groups.first) + #expect(detailModel.signingRequest?.documents == documents) + + let upload = RecordingPDFUploadService() + upload.shouldFail = false + let model = LeadSigningViewModel( + request: request, leadID: detail.lead.id, salesCode: "MKR", repository: repository, + signingService: DefaultPDFSigningService(outputDirectory: folder), + store: LeadSignedDocumentStore(directory: folder), uploadService: upload + ) + #expect(model.pendingDocuments.count == 2) + #expect(model.currentDocument?.id == documents[1].id) + #expect(model.currentDocumentNumber == 2) + #expect(!model.isLastPendingDocument) + await model.loadCurrentDocument() + model.drawing = handwriting() + var signedIDs: [String] = [] + await model.signAndAdvance { document, _ in signedIDs.append(document.id) } + #expect(model.currentDocument?.id == documents[3].id) + #expect(model.currentDocumentNumber == 4) + #expect(model.isLastPendingDocument) + #expect(!model.isComplete) + model.drawing = handwriting() + await model.signAndAdvance { document, _ in signedIDs.append(document.id) } + #expect(model.isComplete) + #expect(model.canFinish) + #expect(signedIDs == [documents[1].id, documents[3].id]) + #expect(upload.calls.count == 1) + #expect(upload.calls.first?.count == 2) + #expect(model.request.documents.count == 5) + } + + @Test + func uploadFailureCanBeRetriedWithoutSigningAgain() async throws { + let folder = try directory() + defer { try? FileManager.default.removeItem(at: folder) } + let repository = DefaultLeadDetailRepository(remoteService: StubLeadDetailRemoteService(delay: .zero)) + let detail = try await repository.detail(id: "L-240318", salesCode: "MKR") + let offer = try #require(detail.offers.first) + let document = try #require(offer.documents.first) + let upload = RecordingPDFUploadService() + let model = LeadSigningViewModel( + request: LeadSigningRequest(offerID: offer.id, signerName: "Klaus Hoffmann", documents: [document]), + leadID: detail.lead.id, salesCode: "MKR", repository: repository, + signingService: DefaultPDFSigningService(outputDirectory: folder), + store: LeadSignedDocumentStore(directory: folder), uploadService: upload + ) + upload.onUpload = { [weak model] in + #expect(model?.isUploading == true) + #expect(model?.canFinish == false) + } + await model.loadCurrentDocument() + model.drawing = handwriting() + var signedCount = 0 + await model.signAndAdvance { _, _ in signedCount += 1 } + #expect(model.isComplete) + #expect(model.uploadError != nil) + #expect(!model.canFinish) + let signedURLs = model.signedURLs + upload.shouldFail = false + await model.uploadSignedDocuments() + #expect(model.canFinish) + #expect(model.uploadError == nil) + #expect(model.signedURLs == signedURLs) + #expect(signedCount == 1) + #expect(upload.calls.count == 2) + #expect(upload.calls.first == upload.calls.last) + await model.uploadSignedDocuments() + #expect(upload.calls.count == 2) + } + + @Test + func successfulSigningAdvancesAndRestoresOnlyMatchingDocumentVersion() async throws { + let folder = try directory() + defer { try? FileManager.default.removeItem(at: folder) } + let repository = DefaultLeadDetailRepository(remoteService: StubLeadDetailRemoteService(delay: .zero)) + let detail = try await repository.detail(id: "L-240318", salesCode: "MKR") + let offer = try #require(detail.offers.first) + let documents = Array(offer.documents.filter { $0.status == .pending }.prefix(2)) + let store = LeadSignedDocumentStore(directory: folder) + let model = LeadSigningViewModel( + request: LeadSigningRequest(offerID: offer.id, signerName: "Klaus Hoffmann", documents: documents), + leadID: detail.lead.id, salesCode: "MKR", repository: repository, + signingService: DefaultPDFSigningService(outputDirectory: folder), store: store, + uploadService: StubLeadSignedPDFUploadService(delay: .zero) + ) + await model.loadCurrentDocument() + #expect(!model.canSign) + model.drawing = handwriting() + await model.signAndAdvance { _, _ in } + #expect(model.currentIndex == 1) + #expect(model.drawing.strokes.isEmpty) + #expect(!model.isComplete) + model.drawing = handwriting() + await model.signAndAdvance { _, _ in } + #expect(model.isComplete) + #expect(!model.canSign) + #expect(store.savedURLs(documents: documents, leadID: detail.lead.id, salesCode: "MKR").count == 2) + #expect(store.savedURLs(documents: documents, leadID: detail.lead.id, salesCode: "OTHER").isEmpty) + let first = try #require(documents.first) + let updated = LeadDocument(id: first.id, title: first.title, pageCount: first.pageCount, + status: .pending, + file: LeadFileReference(id: first.file.id, version: "2", kind: .pdf)) + #expect(store.savedURLs(documents: [updated], leadID: detail.lead.id, salesCode: "MKR").isEmpty) + } +} + +@MainActor +private final class RecordingPDFUploadService: LeadSignedPDFUploadService { + let isSimulation = false + var calls: [[URL]] = [] + var shouldFail = true + var onUpload: (() -> Void)? + + func upload(_ files: [URL], leadID: String, offerID: String, salesCode: String) async throws -> LeadPDFUploadResult { + calls.append(files) + onUpload?() + if shouldFail { throw URLError(.notConnectedToInternet) } + return .uploaded + } +} + +@MainActor +private final class FailingSigningService: PDFSigningService { + var calls = 0 + + func sign(source: URL, drawing: PKDrawing, placement: PDFSignaturePlacement) async throws -> URL { + calls += 1 + throw PDFSigningError.saveFailed + } +}