Add lead workflows, PDF signing, AR projects and photo gallery integration
This commit is contained in:
@ -303,8 +303,8 @@
|
|||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
INFOPLIST_KEY_UIRequiredDeviceCapabilities = arkit;
|
INFOPLIST_KEY_UIRequiredDeviceCapabilities = arkit;
|
||||||
INFOPLIST_KEY_UIStatusBarHidden = YES;
|
INFOPLIST_KEY_UIStatusBarHidden = YES;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
@ -348,8 +348,8 @@
|
|||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
INFOPLIST_KEY_UIRequiredDeviceCapabilities = arkit;
|
INFOPLIST_KEY_UIRequiredDeviceCapabilities = arkit;
|
||||||
INFOPLIST_KEY_UIStatusBarHidden = YES;
|
INFOPLIST_KEY_UIStatusBarHidden = YES;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
@ -506,7 +506,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = 37Z4ZA4QU2;
|
DEVELOPMENT_TEAM = 37Z4ZA4QU2;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.5;
|
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = dev.dennisnemec.GasTankARTests;
|
PRODUCT_BUNDLE_IDENTIFIER = dev.dennisnemec.GasTankARTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@ -528,7 +528,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = 37Z4ZA4QU2;
|
DEVELOPMENT_TEAM = 37Z4ZA4QU2;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.5;
|
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = dev.dennisnemec.GasTankARTests;
|
PRODUCT_BUNDLE_IDENTIFIER = dev.dennisnemec.GasTankARTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
|||||||
12
GasTankAR/Assets.xcassets/LoginLogo.imageset/Contents.json
vendored
Normal file
12
GasTankAR/Assets.xcassets/LoginLogo.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"filename": "LoginLogo.png",
|
||||||
|
"idiom": "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info": {
|
||||||
|
"author": "xcode",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
GasTankAR/Assets.xcassets/LoginLogo.imageset/LoginLogo.png
vendored
Normal file
BIN
GasTankAR/Assets.xcassets/LoginLogo.imageset/LoginLogo.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 825 KiB |
@ -1,23 +1,33 @@
|
|||||||
//
|
|
||||||
// ContentView.swift
|
|
||||||
// GasTankAR
|
|
||||||
//
|
|
||||||
// Created by Dennis Nemec on 19.08.26.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
|
||||||
|
|
||||||
struct ContentView: View {
|
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 {
|
var body: some View {
|
||||||
ProjectListView()
|
if let session {
|
||||||
.modelContainer(for: Project.self)
|
NavigationStack {
|
||||||
|
LeadOverviewView(
|
||||||
|
session: session,
|
||||||
|
repository: DefaultLeadRepository(remoteService: leadService),
|
||||||
|
detailRepository: detailRepository
|
||||||
|
) {
|
||||||
|
self.session = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LoginView(loginUseCase: loginUseCase) {
|
||||||
|
session = $0
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
#Preview {
|
#Preview {
|
||||||
ProjectListView()
|
ContentView()
|
||||||
.modelContainer(PreviewSupport.container)
|
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
|
|||||||
91
GasTankAR/Data/Leads/DefaultLeadDetailRepository.swift
Normal file
91
GasTankAR/Data/Leads/DefaultLeadDetailRepository.swift
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import Foundation
|
||||||
|
import PDFKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class DefaultLeadDetailRepository: LeadDetailRepository {
|
||||||
|
private struct FileKey: Hashable {
|
||||||
|
let reference: LeadFileReference
|
||||||
|
let leadID: String
|
||||||
|
let salesCode: String
|
||||||
|
}
|
||||||
|
|
||||||
|
private let remoteService: any LeadDetailRemoteService
|
||||||
|
private let cacheDirectory: URL
|
||||||
|
private var cachedFiles: [FileKey: URL] = [:]
|
||||||
|
private var downloads: [FileKey: Task<URL, Error>] = [:]
|
||||||
|
|
||||||
|
init(remoteService: any LeadDetailRemoteService,
|
||||||
|
cacheDirectory: URL = FileManager.default.temporaryDirectory.appendingPathComponent("LeadFiles")) {
|
||||||
|
self.remoteService = remoteService
|
||||||
|
self.cacheDirectory = cacheDirectory
|
||||||
|
}
|
||||||
|
|
||||||
|
func detail(id: String, salesCode: String) async throws -> LeadDetail {
|
||||||
|
do {
|
||||||
|
return try await remoteService.fetchDetail(id: id, salesCode: salesCode).toDomain(expectedID: id)
|
||||||
|
} catch {
|
||||||
|
throw DefaultLeadRepository.map(error, leadID: id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func file(_ reference: LeadFileReference, leadID: String, salesCode: String) async throws -> URL {
|
||||||
|
try Task.checkCancellation()
|
||||||
|
let key = FileKey(reference: reference, leadID: leadID, salesCode: salesCode)
|
||||||
|
if let url = cachedFiles[key], FileManager.default.fileExists(atPath: url.path) {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
if let download = downloads[key] {
|
||||||
|
let url = try await download.value
|
||||||
|
try Task.checkCancellation()
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
let directory = cacheDirectory
|
||||||
|
let service = remoteService
|
||||||
|
// A shared download survives one viewer closing; that viewer checks cancellation below.
|
||||||
|
let download = Task {
|
||||||
|
let data: Data
|
||||||
|
do {
|
||||||
|
data = try await service.download(reference, leadID: leadID, salesCode: salesCode)
|
||||||
|
} catch LeadRemoteError.http(404) {
|
||||||
|
throw LeadFileError.notFound
|
||||||
|
} catch {
|
||||||
|
throw DefaultLeadRepository.map(error)
|
||||||
|
}
|
||||||
|
return try await Task.detached {
|
||||||
|
try Self.persist(data, kind: reference.kind, directory: directory)
|
||||||
|
}.value
|
||||||
|
}
|
||||||
|
downloads[key] = download
|
||||||
|
do {
|
||||||
|
let url = try await download.value
|
||||||
|
downloads[key] = nil
|
||||||
|
cachedFiles[key] = url
|
||||||
|
try Task.checkCancellation()
|
||||||
|
return url
|
||||||
|
} catch {
|
||||||
|
downloads[key] = nil
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func persist(_ data: Data, kind: LeadFileKind, directory: URL) throws -> URL {
|
||||||
|
switch kind {
|
||||||
|
case .pdf:
|
||||||
|
guard let document = PDFDocument(data: data), document.pageCount > 0, !document.isLocked else {
|
||||||
|
throw LeadFileError.invalidFile
|
||||||
|
}
|
||||||
|
case .image:
|
||||||
|
guard UIImage(data: data) != nil else { throw LeadFileError.invalidFile }
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||||
|
let url = directory.appendingPathComponent(UUID().uuidString)
|
||||||
|
.appendingPathExtension(kind == .pdf ? "pdf" : "png")
|
||||||
|
try data.write(to: url, options: .atomic)
|
||||||
|
return url
|
||||||
|
} catch {
|
||||||
|
throw LeadFileError.storageUnavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
68
GasTankAR/Data/Leads/DefaultLeadRepository.swift
Normal file
68
GasTankAR/Data/Leads/DefaultLeadRepository.swift
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct DefaultLeadRepository: LeadRepository {
|
||||||
|
let remoteService: any LeadRemoteService
|
||||||
|
|
||||||
|
func leads(for salesCode: String, query: LeadQuery) async throws -> [Lead] {
|
||||||
|
do {
|
||||||
|
let values = try await remoteService.fetchLeads(salesCode: salesCode, query: query)
|
||||||
|
let leads = try values.map { try $0.toDomain() }
|
||||||
|
guard Set(leads.map(\.id)).count == leads.count else {
|
||||||
|
throw LeadError.invalidResponse
|
||||||
|
}
|
||||||
|
return leads
|
||||||
|
} catch {
|
||||||
|
throw Self.map(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func lead(id: String, salesCode: String) async throws -> Lead {
|
||||||
|
do {
|
||||||
|
let value = try await remoteService.fetchLead(id: id, salesCode: salesCode)
|
||||||
|
guard value.id == id else { throw LeadError.invalidResponse }
|
||||||
|
return try value.toDomain()
|
||||||
|
} catch {
|
||||||
|
throw Self.map(error, leadID: id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setBookmark(_ bookmarked: Bool, leadID: String, salesCode: String) async throws -> Lead {
|
||||||
|
do {
|
||||||
|
let value = try await remoteService.setBookmark(bookmarked, id: leadID, salesCode: salesCode)
|
||||||
|
guard value.id == leadID, value.isBookmarked == bookmarked else {
|
||||||
|
throw LeadError.invalidResponse
|
||||||
|
}
|
||||||
|
return try value.toDomain()
|
||||||
|
} catch {
|
||||||
|
throw Self.map(error, leadID: leadID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func map(_ error: Error, leadID: String? = nil) -> Error {
|
||||||
|
if error is CancellationError { return CancellationError() }
|
||||||
|
if let error = error as? LeadError { return error }
|
||||||
|
if let error = error as? URLError {
|
||||||
|
switch error.code {
|
||||||
|
case .cancelled: return CancellationError()
|
||||||
|
case .notConnectedToInternet, .networkConnectionLost: return LeadError.offline
|
||||||
|
case .timedOut: return LeadError.timeout
|
||||||
|
case .cannotFindHost, .cannotConnectToHost, .dnsLookupFailed:
|
||||||
|
return LeadError.serviceUnavailable
|
||||||
|
default: return LeadError.unexpected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let error = error as? LeadRemoteError {
|
||||||
|
switch error {
|
||||||
|
case .http(401): return LeadError.unauthorized
|
||||||
|
case .http(403): return LeadError.forbidden
|
||||||
|
case .http(404):
|
||||||
|
return leadID.map { LeadError.notFound(leadID: $0) } ?? .serviceUnavailable
|
||||||
|
case .http(let status) where status >= 500: return LeadError.serviceUnavailable
|
||||||
|
case .malformedResponse: return LeadError.invalidResponse
|
||||||
|
default: return LeadError.unexpected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if error is DecodingError { return LeadError.invalidResponse }
|
||||||
|
return LeadError.unexpected
|
||||||
|
}
|
||||||
|
}
|
||||||
27
GasTankAR/Data/Leads/LeadARPhotoStore.swift
Normal file
27
GasTankAR/Data/Leads/LeadARPhotoStore.swift
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
/// Reads project photos through the lead association in a fresh context after AR closes.
|
||||||
|
@MainActor
|
||||||
|
final class LeadARPhotoStore {
|
||||||
|
private let context: ModelContext
|
||||||
|
|
||||||
|
init(container: ModelContainer) {
|
||||||
|
context = ModelContext(container)
|
||||||
|
context.autosaveEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func photos(for leadID: String) throws -> [ProjectPhoto] {
|
||||||
|
let links = try context.fetch(FetchDescriptor<LeadARProjectLink>(
|
||||||
|
predicate: #Predicate { $0.leadID == leadID }))
|
||||||
|
let legacy = try context.fetch(FetchDescriptor<Project>(
|
||||||
|
predicate: #Predicate { $0.leadID == leadID }))
|
||||||
|
var projects = links.compactMap(\.project)
|
||||||
|
let linkedIDs = Set(projects.map(\.projectID))
|
||||||
|
projects.append(contentsOf: legacy.filter { !linkedIDs.contains($0.projectID) })
|
||||||
|
var seen = Set<PersistentIdentifier>()
|
||||||
|
return projects.flatMap(\.photos)
|
||||||
|
.filter { seen.insert($0.persistentModelID).inserted }
|
||||||
|
.sorted { $0.creationDate > $1.creationDate }
|
||||||
|
}
|
||||||
|
}
|
||||||
159
GasTankAR/Data/Leads/LeadARProjectsService.swift
Normal file
159
GasTankAR/Data/Leads/LeadARProjectsService.swift
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
/// Translates sales data into AR configurations and owns the persistent association.
|
||||||
|
/// A separate context makes a failed save atomic without rolling back unrelated UI edits.
|
||||||
|
@MainActor
|
||||||
|
final class LeadARProjectsService: ARProjectsProviding {
|
||||||
|
private let detail: LeadDetail
|
||||||
|
private let context: ModelContext
|
||||||
|
private let catalog: any TankCatalogProviding
|
||||||
|
|
||||||
|
init(detail: LeadDetail, container: ModelContainer,
|
||||||
|
catalog: any TankCatalogProviding = TankCatalog.shared) {
|
||||||
|
self.detail = detail
|
||||||
|
self.context = ModelContext(container)
|
||||||
|
self.context.autosaveEnabled = false
|
||||||
|
self.catalog = catalog
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadProjects() throws -> [ARProject] {
|
||||||
|
do {
|
||||||
|
let leadID = detail.lead.id
|
||||||
|
var links = try context.fetch(FetchDescriptor<LeadARProjectLink>(
|
||||||
|
predicate: #Predicate { $0.leadID == leadID }))
|
||||||
|
let legacy = try context.fetch(FetchDescriptor<Project>(
|
||||||
|
predicate: #Predicate { $0.leadID == leadID },
|
||||||
|
sortBy: [SortDescriptor(\.creationDate)]))
|
||||||
|
var linkedIDs = Set(links.compactMap { $0.project?.projectID })
|
||||||
|
|
||||||
|
for offer in detail.offers {
|
||||||
|
guard let tank = offer.tank else { continue }
|
||||||
|
guard catalog.definition(for: tank) != nil else { throw ProjectError.unavailableTank }
|
||||||
|
guard !links.contains(where: { $0.offerID == offer.id && $0.project != nil }) else { continue }
|
||||||
|
// Reuse an unlinked legacy session once, preserving world maps, photos and routes.
|
||||||
|
let project = legacy.first { !linkedIDs.contains($0.projectID) && $0.tank == tank }
|
||||||
|
?? makeProject(name: offer.title, tank: tank)
|
||||||
|
if project.projectName.isEmpty { project.projectName = offer.title }
|
||||||
|
let link = LeadARProjectLink(leadID: leadID, offerID: offer.id, project: project)
|
||||||
|
context.insert(link)
|
||||||
|
links.append(link)
|
||||||
|
linkedIDs.insert(project.projectID)
|
||||||
|
}
|
||||||
|
|
||||||
|
for project in legacy where !linkedIDs.contains(project.projectID) {
|
||||||
|
if project.projectName.isEmpty { project.projectName = "Mein AR-Projekt" }
|
||||||
|
let link = LeadARProjectLink(leadID: leadID, project: project)
|
||||||
|
context.insert(link)
|
||||||
|
links.append(link)
|
||||||
|
}
|
||||||
|
try context.save()
|
||||||
|
return links.compactMap(projectValue).sorted {
|
||||||
|
if $0.source != $1.source { return $0.source == .supplied }
|
||||||
|
return $0.createdAt > $1.createdAt
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
context.rollback()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createProject(name: String, tank: Tank) throws -> ARProject {
|
||||||
|
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { throw ProjectError.missingName }
|
||||||
|
guard catalog.definition(for: tank) != nil else { throw ProjectError.unavailableTank }
|
||||||
|
do {
|
||||||
|
let project = makeProject(name: trimmed, tank: tank)
|
||||||
|
let link = LeadARProjectLink(leadID: detail.lead.id, project: project)
|
||||||
|
context.insert(link)
|
||||||
|
try context.save()
|
||||||
|
return ARProject(id: project.projectID, name: trimmed, tank: tank,
|
||||||
|
createdAt: project.creationDate, source: .custom, reference: nil)
|
||||||
|
} catch {
|
||||||
|
context.rollback()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateProject(id: UUID, name: String, tank: Tank) throws -> ARProject {
|
||||||
|
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { throw ProjectError.missingName }
|
||||||
|
guard catalog.definition(for: tank) != nil else { throw ProjectError.unavailableTank }
|
||||||
|
do {
|
||||||
|
let leadID = detail.lead.id
|
||||||
|
let links = try context.fetch(FetchDescriptor<LeadARProjectLink>(
|
||||||
|
predicate: #Predicate { $0.leadID == leadID }))
|
||||||
|
let matching = links.filter { $0.project?.projectID == id }
|
||||||
|
guard let project = matching.first?.project else { throw ProjectError.notFound }
|
||||||
|
guard matching.allSatisfy({ $0.offerID == nil }) else { throw ProjectError.editingNotAllowed }
|
||||||
|
project.projectName = trimmed
|
||||||
|
project.tank = tank
|
||||||
|
try context.save()
|
||||||
|
return ARProject(id: project.projectID, name: trimmed, tank: tank,
|
||||||
|
createdAt: project.creationDate, source: .custom, reference: nil)
|
||||||
|
} catch {
|
||||||
|
context.rollback()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteProject(id: UUID) throws {
|
||||||
|
do {
|
||||||
|
let leadID = detail.lead.id
|
||||||
|
let links = try context.fetch(FetchDescriptor<LeadARProjectLink>(
|
||||||
|
predicate: #Predicate { $0.leadID == leadID }))
|
||||||
|
let matching = links.filter { $0.project?.projectID == id }
|
||||||
|
guard let project = matching.first?.project else { throw ProjectError.notFound }
|
||||||
|
guard matching.allSatisfy({ $0.offerID == nil }) else {
|
||||||
|
throw ProjectError.deletionNotAllowed
|
||||||
|
}
|
||||||
|
for link in matching {
|
||||||
|
link.project = nil
|
||||||
|
context.delete(link)
|
||||||
|
}
|
||||||
|
context.delete(project)
|
||||||
|
try context.save()
|
||||||
|
} catch {
|
||||||
|
context.rollback()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveProject(id: UUID) throws -> Project {
|
||||||
|
let leadID = detail.lead.id
|
||||||
|
let links = try context.fetch(FetchDescriptor<LeadARProjectLink>(
|
||||||
|
predicate: #Predicate { $0.leadID == leadID }))
|
||||||
|
guard let project = links.compactMap(\.project).first(where: { $0.projectID == id })
|
||||||
|
else { throw ProjectError.notFound }
|
||||||
|
return project
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeProject(name: String, tank: Tank) -> Project {
|
||||||
|
// These compatibility fields are still used by the existing AR photo/export flow.
|
||||||
|
let project = Project(leadID: detail.lead.id, customerName: detail.lead.displayName,
|
||||||
|
creationDate: .now, tank: tank)
|
||||||
|
project.projectName = name
|
||||||
|
context.insert(project)
|
||||||
|
return project
|
||||||
|
}
|
||||||
|
|
||||||
|
private func projectValue(_ link: LeadARProjectLink) -> ARProject? {
|
||||||
|
guard let project = link.project else { return nil }
|
||||||
|
return ARProject(id: project.projectID, name: project.projectName,
|
||||||
|
tank: project.tank, createdAt: project.creationDate,
|
||||||
|
source: link.offerID == nil ? .custom : .supplied, reference: link.offerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ProjectError: LocalizedError {
|
||||||
|
case unavailableTank, missingName, notFound, deletionNotAllowed, editingNotAllowed
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .unavailableTank: "Für diese Tankkonfiguration ist kein Modell verfügbar."
|
||||||
|
case .missingName: "Bitte gib einen Projektnamen ein."
|
||||||
|
case .deletionNotAllowed: "Bereitgestellte Projekte können nicht gelöscht werden."
|
||||||
|
case .editingNotAllowed: "Bereitgestellte Projekte können nicht bearbeitet werden."
|
||||||
|
case .notFound: "Das Projekt konnte nicht gefunden werden."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
29
GasTankAR/Data/Leads/LeadDetailRemoteService.swift
Normal file
29
GasTankAR/Data/Leads/LeadDetailRemoteService.swift
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
protocol LeadDetailRemoteService {
|
||||||
|
func fetchDetail(id: String, salesCode: String) async throws -> LeadDetailDTO
|
||||||
|
func download(_ reference: LeadFileReference, leadID: String, salesCode: String) async throws -> Data
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transport boundary. A future HTTP adapter maps its response into this payload.
|
||||||
|
struct LeadDetailDTO {
|
||||||
|
let detail: LeadDetail
|
||||||
|
|
||||||
|
func toDomain(expectedID: String) throws -> LeadDetail {
|
||||||
|
let documents = detail.offers.flatMap(\.documents)
|
||||||
|
let groups = detail.offers.flatMap(\.groups)
|
||||||
|
guard detail.lead.id == expectedID,
|
||||||
|
Set(detail.offers.map(\.id)).count == detail.offers.count,
|
||||||
|
Set(groups.map(\.id)).count == groups.count,
|
||||||
|
Set(documents.map(\.id)).count == documents.count,
|
||||||
|
Set(detail.photos.map(\.id)).count == detail.photos.count,
|
||||||
|
Set(detail.tankOptions.map(\.id)).count == detail.tankOptions.count,
|
||||||
|
detail.offers.allSatisfy({ !$0.id.isEmpty && !$0.title.isEmpty && $0.amount >= 0 }),
|
||||||
|
documents.allSatisfy({ !$0.id.isEmpty && !$0.title.isEmpty && $0.pageCount > 0
|
||||||
|
&& $0.file.kind == .pdf && !$0.file.id.isEmpty && !$0.file.version.isEmpty }),
|
||||||
|
detail.photos.allSatisfy({ $0.thumbnail.kind == .image && $0.original.kind == .image })
|
||||||
|
else { throw LeadError.invalidResponse }
|
||||||
|
return detail
|
||||||
|
}
|
||||||
|
}
|
||||||
92
GasTankAR/Data/Leads/LeadDetailSampleData.swift
Normal file
92
GasTankAR/Data/Leads/LeadDetailSampleData.swift
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import Foundation
|
||||||
|
import CoreGraphics
|
||||||
|
|
||||||
|
enum LeadDetailSampleData {
|
||||||
|
static let tanks = [
|
||||||
|
LeadTankOption(id: "2700", title: "2.700 l", tank: Tank(size: .t1_2)),
|
||||||
|
LeadTankOption(id: "4850", title: "4.850 l", tank: Tank(size: .t2_1)),
|
||||||
|
LeadTankOption(id: "underground", title: "Unterirdisch",
|
||||||
|
tank: Tank(size: .t1_2, color: .green, placement: .belowGround))
|
||||||
|
]
|
||||||
|
|
||||||
|
static func make(for lead: Lead) -> LeadDetail {
|
||||||
|
let isHoffmann = lead.id == "L-240318"
|
||||||
|
let offers = isHoffmann ? hoffmannOffers(leadID: lead.id) : genericOffers(for: lead)
|
||||||
|
let photos = isHoffmann ? (1...10).map { index in
|
||||||
|
LeadPhoto(id: "\(lead.id)-photo-\(index)", title: "Standortaufnahme \(index)", isAR: index < 3,
|
||||||
|
thumbnail: LeadFileReference(id: "\(lead.id)-thumb-\(index)", version: "1", kind: .image),
|
||||||
|
original: LeadFileReference(id: "\(lead.id)-image-\(index)", version: "1", kind: .image))
|
||||||
|
} : []
|
||||||
|
let name = lead.contact.map { [$0.title, $0.firstName, $0.lastName].compactMap { $0 }.joined(separator: " ") }
|
||||||
|
return LeadDetail(lead: lead, customerType: lead.companyName == nil ? "Privatkunde" : "Gewerbekunde",
|
||||||
|
contactName: isHoffmann ? "Klaus Hoffmann" : name,
|
||||||
|
contactRole: isHoffmann ? "Geschäftsführer" : nil,
|
||||||
|
mobile: isHoffmann ? "0171 338 4206" : nil,
|
||||||
|
email: isHoffmann ? "info@baeckerei-hoffmann.de" : nil,
|
||||||
|
tankOptions: tanks, offers: offers, photos: photos)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func document(_ id: String, _ title: String, pages: Int, signed: Bool = false) -> LeadDocument {
|
||||||
|
LeadDocument(id: id, title: title, pageCount: pages, status: signed ? .signed : .pending,
|
||||||
|
file: LeadFileReference(id: id, version: "signature-preview-1", kind: .pdf),
|
||||||
|
signaturePlacement: PDFSignaturePlacement(
|
||||||
|
pageIndex: 0, bounds: CGRect(x: 310, y: 76, width: 237, height: 60)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func date(_ month: Int, _ day: Int) -> Date? {
|
||||||
|
Calendar(identifier: .gregorian).date(from: DateComponents(year: 2026, month: month, day: day, hour: 12))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func hoffmannOffers(leadID: String) -> [LeadOffer] {
|
||||||
|
let prefix = "\(leadID)-"
|
||||||
|
let contracts = LeadDocumentGroup(id: prefix + "contracts", title: "Vertragsunterlagen", documents: [
|
||||||
|
document(prefix + "purchase", "Kaufvertrag Flüssiggastank.pdf", pages: 4),
|
||||||
|
document(prefix + "gas", "Gasliefervertrag.pdf", pages: 6),
|
||||||
|
document(prefix + "sepa", "SEPA-Lastschriftmandat.pdf", pages: 1)
|
||||||
|
])
|
||||||
|
let technical = LeadDocumentGroup(id: prefix + "technical", title: "Technik & Sicherheit", documents: [
|
||||||
|
document(prefix + "plan", "Aufstellungsplan Tank.pdf", pages: 2),
|
||||||
|
document(prefix + "safety", "Sicherheitsdatenblatt Propan.pdf", pages: 3, signed: true)
|
||||||
|
])
|
||||||
|
let privacy = LeadDocumentGroup(id: prefix + "privacy", title: "Datenschutz", documents: [
|
||||||
|
document(prefix + "consent", "Datenschutzeinwilligung.pdf", pages: 1, signed: true)
|
||||||
|
])
|
||||||
|
let rental = LeadDocumentGroup(id: prefix + "rental", title: "Vertragsunterlagen", documents: [
|
||||||
|
document(prefix + "rental-contract", "Mietvertrag Flüssiggastank.pdf", pages: 5),
|
||||||
|
document(prefix + "rental-gas", "Gasliefervertrag.pdf", pages: 6),
|
||||||
|
document(prefix + "rental-sepa", "SEPA-Lastschriftmandat.pdf", pages: 1),
|
||||||
|
document(prefix + "rental-plan", "Aufstellungsplan.pdf", pages: 2),
|
||||||
|
document(prefix + "rental-privacy", "Datenschutzeinwilligung.pdf", pages: 1)
|
||||||
|
])
|
||||||
|
return [
|
||||||
|
LeadOffer(id: "A-2026-1182", title: "Flüssiggastank 2.700 l oberirdisch", kind: .purchase,
|
||||||
|
amount: 4980, billing: .once, validUntil: date(11, 30), acceptedAt: nil,
|
||||||
|
groups: [contracts, technical, privacy], tank: Tank(size: .t1_2)),
|
||||||
|
LeadOffer(id: "A-2026-1197", title: "Flüssiggastank 4.850 l unterirdisch", kind: .rental,
|
||||||
|
amount: 39, billing: .monthly, validUntil: date(12, 15), acceptedAt: nil, groups: [rental],
|
||||||
|
tank: Tank(size: .t2_1, color: .olive, placement: .belowGround)),
|
||||||
|
LeadOffer(id: "A-2026-1043", title: "Wartungsvertrag Gasanlage", kind: .service,
|
||||||
|
amount: 129, billing: .annually, validUntil: nil, acceptedAt: date(10, 30),
|
||||||
|
groups: [LeadDocumentGroup(id: prefix + "service", title: "Vertragsunterlagen", documents: [
|
||||||
|
document(prefix + "maintenance", "Wartungsvertrag Gasanlage.pdf", pages: 3, signed: true)
|
||||||
|
])])
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func genericOffers(for lead: Lead) -> [LeadOffer] {
|
||||||
|
guard lead.offerCount > 0 else { return [] }
|
||||||
|
return (0..<lead.offerCount).map { index in
|
||||||
|
let count = lead.unsignedContractCount / lead.offerCount
|
||||||
|
+ (index < lead.unsignedContractCount % lead.offerCount ? 1 : 0)
|
||||||
|
let documents = (0..<max(1, count)).map { number in
|
||||||
|
document("\(lead.id)-offer-\(index)-doc-\(number)", "Vertragsunterlage \(number + 1).pdf",
|
||||||
|
pages: 2, signed: count == 0)
|
||||||
|
}
|
||||||
|
return LeadOffer(id: "\(lead.id)-A-\(index + 1)", title: "Flüssiggastank 2.700 l oberirdisch",
|
||||||
|
kind: .purchase, amount: 4980, billing: .once,
|
||||||
|
validUntil: date(11, 30), acceptedAt: nil,
|
||||||
|
groups: [LeadDocumentGroup(id: "\(lead.id)-group-\(index)",
|
||||||
|
title: "Vertragsunterlagen", documents: documents)], tank: Tank(size: .t1_2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
58
GasTankAR/Data/Leads/LeadRemoteService.swift
Normal file
58
GasTankAR/Data/Leads/LeadRemoteService.swift
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Transport boundary. A future URLSession implementation must enforce a finite
|
||||||
|
/// request timeout and throw HTTP errors before decoding the response body.
|
||||||
|
@MainActor
|
||||||
|
protocol LeadRemoteService {
|
||||||
|
func fetchLeads(salesCode: String, query: LeadQuery) async throws -> [LeadDTO]
|
||||||
|
func fetchLead(id: String, salesCode: String) async throws -> LeadDTO
|
||||||
|
func setBookmark(_ bookmarked: Bool, id: String, salesCode: String) async throws -> LeadDTO
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LeadRemoteError: Error {
|
||||||
|
case http(statusCode: Int)
|
||||||
|
case malformedResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provisional transport shape, independent of the UI and domain model.
|
||||||
|
struct LeadDTO: Codable, Sendable {
|
||||||
|
let id: String
|
||||||
|
let companyName: String?
|
||||||
|
let contactTitle: String?
|
||||||
|
let firstName: String?
|
||||||
|
let lastName: String?
|
||||||
|
let street: String?
|
||||||
|
let postalCode: String
|
||||||
|
let city: String
|
||||||
|
let phoneNumber: String?
|
||||||
|
let createdAt: Date
|
||||||
|
let offerCount: Int
|
||||||
|
let unsignedContractCount: Int
|
||||||
|
var isBookmarked: Bool
|
||||||
|
|
||||||
|
func toDomain() throws -> Lead {
|
||||||
|
func clean(_ value: String?) -> String? {
|
||||||
|
guard let text = value?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!text.isEmpty else { return nil }
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
let company = clean(companyName)
|
||||||
|
let first = clean(firstName)
|
||||||
|
let last = clean(lastName)
|
||||||
|
guard clean(id) != nil, company != nil || first != nil || last != nil,
|
||||||
|
clean(postalCode) != nil, clean(city) != nil,
|
||||||
|
offerCount >= 0, unsignedContractCount >= 0 else {
|
||||||
|
throw LeadError.invalidResponse
|
||||||
|
}
|
||||||
|
let contact = first != nil || last != nil
|
||||||
|
? LeadContact(title: clean(contactTitle), firstName: first ?? "", lastName: last ?? "")
|
||||||
|
: nil
|
||||||
|
return Lead(
|
||||||
|
id: id, companyName: company, contact: contact,
|
||||||
|
address: LeadAddress(street: clean(street), postalCode: postalCode, city: city),
|
||||||
|
phoneNumber: clean(phoneNumber), createdAt: createdAt,
|
||||||
|
offerCount: offerCount, unsignedContractCount: unsignedContractCount,
|
||||||
|
isBookmarked: isBookmarked
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
111
GasTankAR/Data/Leads/StubLeadDetailRemoteService.swift
Normal file
111
GasTankAR/Data/Leads/StubLeadDetailRemoteService.swift
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class StubLeadDetailRemoteService: LeadDetailRemoteService {
|
||||||
|
enum Scenario { case success, offline, timeout, http(Int), invalidFile }
|
||||||
|
var detailScenario: Scenario = .success
|
||||||
|
var fileScenario: Scenario = .success
|
||||||
|
var delay: Duration
|
||||||
|
private(set) var downloadCount = 0
|
||||||
|
private let leads: any LeadRemoteService
|
||||||
|
|
||||||
|
init(leads: (any LeadRemoteService)? = nil,
|
||||||
|
delay: Duration = .milliseconds(400)) {
|
||||||
|
self.leads = leads ?? StubLeadRemoteService(delay: .zero)
|
||||||
|
self.delay = delay
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchDetail(id: String, salesCode: String) async throws -> LeadDetailDTO {
|
||||||
|
try await simulate(detailScenario)
|
||||||
|
let lead = try await leads.fetchLead(id: id, salesCode: salesCode).toDomain()
|
||||||
|
return LeadDetailDTO(detail: LeadDetailSampleData.make(for: lead))
|
||||||
|
}
|
||||||
|
|
||||||
|
func download(_ reference: LeadFileReference, leadID: String, salesCode: String) async throws -> Data {
|
||||||
|
downloadCount += 1
|
||||||
|
try await simulate(fileScenario)
|
||||||
|
if case .invalidFile = fileScenario { return Data("not a valid file".utf8) }
|
||||||
|
let lead = try await leads.fetchLead(id: leadID, salesCode: salesCode).toDomain()
|
||||||
|
let detail = LeadDetailSampleData.make(for: lead)
|
||||||
|
switch reference.kind {
|
||||||
|
case .pdf:
|
||||||
|
guard let document = detail.offers.flatMap(\.documents).first(where: { $0.file == reference }) else {
|
||||||
|
throw LeadRemoteError.http(statusCode: 404)
|
||||||
|
}
|
||||||
|
return makePDF(document: document, customer: lead.displayName)
|
||||||
|
case .image:
|
||||||
|
guard let photo = detail.photos.first(where: { $0.thumbnail == reference || $0.original == reference }) else {
|
||||||
|
throw LeadRemoteError.http(statusCode: 404)
|
||||||
|
}
|
||||||
|
return makeImage(photo: photo, thumbnail: reference == photo.thumbnail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func simulate(_ scenario: Scenario) async throws {
|
||||||
|
try await Task.sleep(for: delay)
|
||||||
|
try Task.checkCancellation()
|
||||||
|
switch scenario {
|
||||||
|
case .success, .invalidFile: break
|
||||||
|
case .offline: throw URLError(.notConnectedToInternet)
|
||||||
|
case .timeout: throw URLError(.timedOut)
|
||||||
|
case .http(let code): throw LeadRemoteError.http(statusCode: code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makePDF(document: LeadDocument, customer: String) -> Data {
|
||||||
|
let renderer = UIGraphicsPDFRenderer(bounds: CGRect(x: 0, y: 0, width: 595, height: 842))
|
||||||
|
return renderer.pdfData { context in
|
||||||
|
for page in 1...document.pageCount {
|
||||||
|
context.beginPage()
|
||||||
|
let title = document.title.replacingOccurrences(of: ".pdf", with: "")
|
||||||
|
(title as NSString).draw(in: CGRect(x: 48, y: 56, width: 499, height: 100),
|
||||||
|
withAttributes: [.font: UIFont.boldSystemFont(ofSize: 24)])
|
||||||
|
let body = """
|
||||||
|
DrachenGas – Beispieldokument
|
||||||
|
\(customer)
|
||||||
|
|
||||||
|
Seite \(page) von \(document.pageCount)
|
||||||
|
|
||||||
|
Diese Datei dient ausschließlich zur Vorschau der Download- und PDF-Anzeige.
|
||||||
|
Sie enthält keine verbindlichen Vertragsbedingungen.
|
||||||
|
|
||||||
|
Bitte prüfen Sie alle Seiten vor der Unterschrift.
|
||||||
|
"""
|
||||||
|
(body as NSString).draw(in: CGRect(x: 48, y: 180, width: 499, height: 540),
|
||||||
|
withAttributes: [.font: UIFont.systemFont(ofSize: 15),
|
||||||
|
.foregroundColor: UIColor.darkGray])
|
||||||
|
if let placement = document.signaturePlacement, placement.pageIndex == page - 1 {
|
||||||
|
let bounds = placement.bounds
|
||||||
|
let field = CGRect(x: bounds.minX, y: 842 - bounds.maxY,
|
||||||
|
width: bounds.width, height: bounds.height)
|
||||||
|
UIColor.lightGray.setStroke()
|
||||||
|
UIBezierPath(rect: field).stroke()
|
||||||
|
("Unterschrift Kunde" as NSString).draw(
|
||||||
|
at: CGPoint(x: field.minX, y: field.maxY + 8),
|
||||||
|
withAttributes: [.font: UIFont.systemFont(ofSize: 11),
|
||||||
|
.foregroundColor: UIColor.darkGray]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeImage(photo: LeadPhoto, thumbnail: Bool) -> Data {
|
||||||
|
let size = thumbnail ? CGSize(width: 360, height: 220) : CGSize(width: 1200, height: 800)
|
||||||
|
let renderer = UIGraphicsImageRenderer(size: size)
|
||||||
|
return renderer.pngData { context in
|
||||||
|
UIColor.systemTeal.withAlphaComponent(0.14).setFill()
|
||||||
|
context.fill(CGRect(origin: .zero, size: size))
|
||||||
|
let symbol = UIImage(systemName: photo.isAR ? "arkit" : "photo")?
|
||||||
|
.withTintColor(.systemBlue, renderingMode: .alwaysOriginal)
|
||||||
|
symbol?.draw(in: CGRect(x: size.width * 0.4, y: size.height * 0.2,
|
||||||
|
width: size.width * 0.2, height: size.height * 0.35))
|
||||||
|
("Beispiel · \(photo.title)" as NSString).draw(
|
||||||
|
in: CGRect(x: 20, y: size.height * 0.72, width: size.width - 40, height: 60),
|
||||||
|
withAttributes: [.font: UIFont.systemFont(ofSize: thumbnail ? 16 : 32),
|
||||||
|
.foregroundColor: UIColor.darkGray]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
129
GasTankAR/Data/Leads/StubLeadRemoteService.swift
Normal file
129
GasTankAR/Data/Leads/StubLeadRemoteService.swift
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// In-memory server substitute. Bookmarks are isolated by sales code.
|
||||||
|
/// All error scenarios can be injected without special user-facing search strings.
|
||||||
|
@MainActor
|
||||||
|
final class StubLeadRemoteService: LeadRemoteService {
|
||||||
|
enum Scenario {
|
||||||
|
case success, offline, timeout, http(Int), malformedResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
var scenario: Scenario
|
||||||
|
var delay: Duration
|
||||||
|
var listDelay: Duration?
|
||||||
|
private var records: [LeadDTO]
|
||||||
|
private var bookmarksByUser: [String: Set<String>] = [:]
|
||||||
|
|
||||||
|
init(scenario: Scenario = .success, delay: Duration = .milliseconds(350),
|
||||||
|
records: [LeadDTO] = LeadSampleData.records, listDelay: Duration? = nil) {
|
||||||
|
self.scenario = scenario
|
||||||
|
self.delay = delay
|
||||||
|
self.listDelay = listDelay
|
||||||
|
self.records = records
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchLeads(salesCode: String, query: LeadQuery) async throws -> [LeadDTO] {
|
||||||
|
try await simulate(delay: listDelay)
|
||||||
|
let terms = query.normalized.searchText.split(whereSeparator: \.isWhitespace).map(String.init)
|
||||||
|
var result = records.map { personalized($0, salesCode: salesCode) }.filter { lead in
|
||||||
|
let fields = [lead.id, lead.companyName, lead.contactTitle, lead.firstName,
|
||||||
|
lead.lastName, lead.street, lead.postalCode, lead.city]
|
||||||
|
.compactMap { $0 }.joined(separator: " ")
|
||||||
|
return terms.allSatisfy { fields.localizedStandardContains($0) }
|
||||||
|
&& (query.filter == .all || lead.unsignedContractCount > 0)
|
||||||
|
}
|
||||||
|
result.sort {
|
||||||
|
switch query.sort {
|
||||||
|
case .newest:
|
||||||
|
return $0.createdAt == $1.createdAt ? $0.id < $1.id : $0.createdAt > $1.createdAt
|
||||||
|
case .nameAscending:
|
||||||
|
let comparison = name($0).localizedStandardCompare(name($1))
|
||||||
|
return comparison == .orderedSame ? $0.id < $1.id : comparison == .orderedAscending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchLead(id: String, salesCode: String) async throws -> LeadDTO {
|
||||||
|
try await simulate()
|
||||||
|
guard let record = records.first(where: { $0.id == id }) else {
|
||||||
|
throw LeadRemoteError.http(statusCode: 404)
|
||||||
|
}
|
||||||
|
return personalized(record, salesCode: salesCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setBookmark(_ bookmarked: Bool, id: String, salesCode: String) async throws -> LeadDTO {
|
||||||
|
try await simulate()
|
||||||
|
guard let record = records.first(where: { $0.id == id }) else {
|
||||||
|
throw LeadRemoteError.http(statusCode: 404)
|
||||||
|
}
|
||||||
|
var bookmarks = bookmarks(for: salesCode)
|
||||||
|
if bookmarked { bookmarks.insert(id) } else { bookmarks.remove(id) }
|
||||||
|
bookmarksByUser[salesCode] = bookmarks
|
||||||
|
return personalized(record, salesCode: salesCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func simulate(delay override: Duration? = nil) async throws {
|
||||||
|
try await Task.sleep(for: override ?? delay)
|
||||||
|
try Task.checkCancellation()
|
||||||
|
switch scenario {
|
||||||
|
case .success: break
|
||||||
|
case .offline: throw URLError(.notConnectedToInternet)
|
||||||
|
case .timeout: throw URLError(.timedOut)
|
||||||
|
case .http(let status): throw LeadRemoteError.http(statusCode: status)
|
||||||
|
case .malformedResponse: throw LeadRemoteError.malformedResponse
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bookmarks(for salesCode: String) -> Set<String> {
|
||||||
|
bookmarksByUser[salesCode] ?? Set(records.filter(\.isBookmarked).map(\.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func personalized(_ record: LeadDTO, salesCode: String) -> LeadDTO {
|
||||||
|
var value = record
|
||||||
|
value.isBookmarked = bookmarks(for: salesCode).contains(record.id)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
private func name(_ record: LeadDTO) -> String {
|
||||||
|
record.companyName ?? [record.contactTitle, record.firstName, record.lastName]
|
||||||
|
.compactMap { $0 }.joined(separator: " ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LeadSampleData {
|
||||||
|
static let records: [LeadDTO] = [
|
||||||
|
make("L-240330", first: "Thomas", last: "Brenner", street: "Am Schlossberg 9",
|
||||||
|
postal: "79111", city: "Freiburg", phone: "0160 448 2210", offers: 1, contracts: 2, day: 30),
|
||||||
|
make("L-240326", company: "Schreinerei Vogt", street: "Talweg 22",
|
||||||
|
postal: "79252", city: "Stegen", phone: "07661 37 12", day: 26),
|
||||||
|
make("L-240322", company: "Hotel Schwarzwaldblick", street: "Höhenweg 1",
|
||||||
|
postal: "79874", city: "Breitnau", phone: "07652 91 80", offers: 2, contracts: 6, day: 22),
|
||||||
|
make("L-240318", company: "Bäckerei Hoffmann GmbH", street: "Marktstraße 14",
|
||||||
|
postal: "79098", city: "Freiburg", phone: "0761 204 118", offers: 2, contracts: 5, day: 18, bookmarked: true),
|
||||||
|
make("L-240309", company: "Kita Sonnenland e.V.", street: "Schulstraße 5",
|
||||||
|
postal: "79224", city: "Umkirch", phone: "07665 50 02 1", offers: 1, contracts: 1, day: 9),
|
||||||
|
make("L-240298", company: "Familie Yilmaz", street: "Rosenweg 17",
|
||||||
|
postal: "79194", city: "Gundelfingen", phone: "0152 019 33 87", day: 8),
|
||||||
|
make("L-240291", title: "Dr.", first: "Sabine", last: "Wendt", street: "Gartenstraße 8",
|
||||||
|
postal: "79100", city: "Freiburg", phone: "0171 552 0193", offers: 1, contracts: 3, day: 7, bookmarked: true),
|
||||||
|
make("L-240277", company: "Autohaus Kessler KG", street: "Gewerbestraße 3",
|
||||||
|
postal: "79199", city: "Kirchzarten", phone: "07661 90 33 0", offers: 3, contracts: 8, day: 6, bookmarked: true),
|
||||||
|
make("L-240270", company: "Gärtnerei Weber", street: "Feldweg 4",
|
||||||
|
postal: "79206", city: "Breisach", phone: nil, day: 5),
|
||||||
|
make("L-240261", first: "Anna", last: "Schneider", street: "Bergstraße 6",
|
||||||
|
postal: "79249", city: "Merzhausen", phone: "0761 555 012", offers: 1, day: 4)
|
||||||
|
]
|
||||||
|
|
||||||
|
private static func make(
|
||||||
|
_ id: String, company: String? = nil, title: String? = nil,
|
||||||
|
first: String? = nil, last: String? = nil, street: String,
|
||||||
|
postal: String, city: String, phone: String?, offers: Int = 0,
|
||||||
|
contracts: Int = 0, day: Int, bookmarked: Bool = false
|
||||||
|
) -> LeadDTO {
|
||||||
|
LeadDTO(id: id, companyName: company, contactTitle: title, firstName: first,
|
||||||
|
lastName: last, street: street, postalCode: postal, city: city,
|
||||||
|
phoneNumber: phone, createdAt: Date(timeIntervalSince1970: 1_710_000_000 + Double(day) * 86_400),
|
||||||
|
offerCount: offers, unsignedContractCount: contracts, isBookmarked: bookmarked)
|
||||||
|
}
|
||||||
|
}
|
||||||
32
GasTankAR/Domain/AR/ARProject.swift
Normal file
32
GasTankAR/Domain/AR/ARProject.swift
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
36
GasTankAR/Domain/Leads/Lead.swift
Normal file
36
GasTankAR/Domain/Leads/Lead.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
77
GasTankAR/Domain/Leads/LeadDetail.swift
Normal file
77
GasTankAR/Domain/Leads/LeadDetail.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
34
GasTankAR/Domain/Leads/LeadDetailRepository.swift
Normal file
34
GasTankAR/Domain/Leads/LeadDetailRepository.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
23
GasTankAR/Domain/Leads/LeadQuery.swift
Normal file
23
GasTankAR/Domain/Leads/LeadQuery.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
8
GasTankAR/Domain/Leads/LeadRepository.swift
Normal file
8
GasTankAR/Domain/Leads/LeadRepository.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
33
GasTankAR/Domain/Leads/LeadUseCases.swift
Normal file
33
GasTankAR/Domain/Leads/LeadUseCases.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
16
GasTankAR/Model/LeadARProjectLink.swift
Normal file
16
GasTankAR/Model/LeadARProjectLink.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -11,6 +11,9 @@ import ARKit
|
|||||||
|
|
||||||
@Model
|
@Model
|
||||||
final class Project {
|
final class Project {
|
||||||
|
var projectID: UUID = UUID()
|
||||||
|
var projectName: String = ""
|
||||||
|
|
||||||
/// Identifier given by the user for the project. In DOCUframe terms
|
/// Identifier given by the user for the project. In DOCUframe terms
|
||||||
/// it is the LeadID.
|
/// it is the LeadID.
|
||||||
var leadID: String
|
var leadID: String
|
||||||
|
|||||||
22
GasTankAR/Services/AuthenticationService.swift
Normal file
22
GasTankAR/Services/AuthenticationService.swift
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Domain boundary; the concrete server integration will replace the stub.
|
||||||
|
protocol AuthenticationService: Sendable {
|
||||||
|
func signIn(salesCode: String) async throws -> LoginSession
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LoginSession: Equatable, Sendable {
|
||||||
|
let salesCode: String
|
||||||
|
let displayName: String?
|
||||||
|
|
||||||
|
init(salesCode: String, displayName: String? = nil) {
|
||||||
|
self.salesCode = salesCode
|
||||||
|
self.displayName = displayName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LoginError: Error, Equatable {
|
||||||
|
case invalidSalesCode
|
||||||
|
case rejected
|
||||||
|
case unavailable
|
||||||
|
}
|
||||||
28
GasTankAR/Services/CustomerLocationService.swift
Normal file
28
GasTankAR/Services/CustomerLocationService.swift
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import MapKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
protocol CustomerLocationService {
|
||||||
|
func coordinate(for address: String) async throws -> CLLocationCoordinate2D
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CustomerLocationError: Error {
|
||||||
|
case notFound
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
struct AppleMapsCustomerLocationService: CustomerLocationService {
|
||||||
|
func coordinate(for address: String) async throws -> CLLocationCoordinate2D {
|
||||||
|
guard let request = MKGeocodingRequest(addressString: address) else {
|
||||||
|
throw CustomerLocationError.notFound
|
||||||
|
}
|
||||||
|
let items: [MKMapItem]
|
||||||
|
do {
|
||||||
|
items = try await request.mapItems
|
||||||
|
} catch let error as MKError where error.code == .placemarkNotFound {
|
||||||
|
throw CustomerLocationError.notFound
|
||||||
|
}
|
||||||
|
try Task.checkCancellation()
|
||||||
|
guard let item = items.first else { throw CustomerLocationError.notFound }
|
||||||
|
return item.location.coordinate
|
||||||
|
}
|
||||||
|
}
|
||||||
30
GasTankAR/Services/LoginUseCase.swift
Normal file
30
GasTankAR/Services/LoginUseCase.swift
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol LoginUseCase: Sendable {
|
||||||
|
func execute(salesCode: String) async throws -> LoginSession
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared by the use case and the form so that both enforce the same rule.
|
||||||
|
enum SalesCodeValidator {
|
||||||
|
static func normalized(_ input: String) -> String {
|
||||||
|
input.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isValid(_ input: String) -> Bool {
|
||||||
|
normalized(input).count >= 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DefaultLoginUseCase: LoginUseCase {
|
||||||
|
let authenticationService: any AuthenticationService
|
||||||
|
|
||||||
|
func execute(salesCode: String) async throws -> LoginSession {
|
||||||
|
guard SalesCodeValidator.isValid(salesCode) else {
|
||||||
|
throw LoginError.invalidSalesCode
|
||||||
|
}
|
||||||
|
try Task.checkCancellation()
|
||||||
|
return try await authenticationService.signIn(
|
||||||
|
salesCode: SalesCodeValidator.normalized(salesCode)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
76
GasTankAR/Services/Signing/DefaultPDFSigningService.swift
Normal file
76
GasTankAR/Services/Signing/DefaultPDFSigningService.swift
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import Foundation
|
||||||
|
import PDFKit
|
||||||
|
import PencilKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
struct DefaultPDFSigningService: PDFSigningService {
|
||||||
|
var outputDirectory: URL = URL.applicationSupportDirectory
|
||||||
|
.appending(path: "SignedLeadDocuments", directoryHint: .isDirectory)
|
||||||
|
|
||||||
|
func sign(source: URL, drawing: PKDrawing, placement: PDFSignaturePlacement) async throws -> URL {
|
||||||
|
try Task.checkCancellation()
|
||||||
|
guard !drawing.strokes.isEmpty else { throw PDFSigningError.emptySignature }
|
||||||
|
guard let document = PDFDocument(url: source), !document.isLocked else {
|
||||||
|
throw PDFSigningError.invalidDocument
|
||||||
|
}
|
||||||
|
guard placement.pageIndex >= 0,
|
||||||
|
let page = document.page(at: placement.pageIndex),
|
||||||
|
placement.bounds.width > 0, placement.bounds.height > 0,
|
||||||
|
page.bounds(for: .mediaBox).contains(placement.bounds) else {
|
||||||
|
throw PDFSigningError.invalidPlacement
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fit the handwriting into the configured field without changing its aspect ratio.
|
||||||
|
// PencilKit uses a top-left origin; PDF page coordinates use a bottom-left origin.
|
||||||
|
let sourceBounds = drawing.bounds.insetBy(dx: -2, dy: -2)
|
||||||
|
let target = placement.bounds.insetBy(dx: 4, dy: 4)
|
||||||
|
guard target.width > 0, target.height > 0,
|
||||||
|
sourceBounds.width.isFinite, sourceBounds.height.isFinite else {
|
||||||
|
throw PDFSigningError.invalidPlacement
|
||||||
|
}
|
||||||
|
let scale = min(target.width / sourceBounds.width, target.height / sourceBounds.height)
|
||||||
|
let origin = CGPoint(x: target.midX - sourceBounds.width * scale / 2,
|
||||||
|
y: target.midY - sourceBounds.height * scale / 2)
|
||||||
|
let annotation = PDFAnnotation(bounds: placement.bounds, forType: .ink, withProperties: nil)
|
||||||
|
annotation.color = .black
|
||||||
|
let border = PDFBorder()
|
||||||
|
border.lineWidth = max(0.7, 2 * scale)
|
||||||
|
annotation.border = border
|
||||||
|
annotation.shouldPrint = true
|
||||||
|
|
||||||
|
for stroke in drawing.strokes {
|
||||||
|
let path = UIBezierPath()
|
||||||
|
var isFirst = true
|
||||||
|
for point in stroke.path.interpolatedPoints(by: .distance(1)) {
|
||||||
|
let location = point.location.applying(stroke.transform)
|
||||||
|
let converted = CGPoint(
|
||||||
|
x: origin.x + (location.x - sourceBounds.minX) * scale,
|
||||||
|
y: origin.y + (sourceBounds.maxY - location.y) * scale
|
||||||
|
)
|
||||||
|
if isFirst {
|
||||||
|
path.move(to: converted)
|
||||||
|
isFirst = false
|
||||||
|
} else {
|
||||||
|
path.addLine(to: converted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A tap is also a valid ink mark.
|
||||||
|
if path.isEmpty == false {
|
||||||
|
path.addLine(to: CGPoint(x: path.currentPoint.x + 0.01, y: path.currentPoint.y))
|
||||||
|
annotation.add(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
page.addAnnotation(annotation)
|
||||||
|
guard let data = document.dataRepresentation() else { throw PDFSigningError.saveFailed }
|
||||||
|
try Task.checkCancellation()
|
||||||
|
do {
|
||||||
|
try FileManager.default.createDirectory(at: outputDirectory, withIntermediateDirectories: true)
|
||||||
|
let destination = outputDirectory.appending(path: UUID().uuidString + ".pdf")
|
||||||
|
try data.write(to: destination, options: .atomic)
|
||||||
|
return destination
|
||||||
|
} catch {
|
||||||
|
throw PDFSigningError.saveFailed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
47
GasTankAR/Services/Signing/LeadSignedDocumentStore.swift
Normal file
47
GasTankAR/Services/Signing/LeadSignedDocumentStore.swift
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Keeps locally signed copies discoverable after reopening a lead or restarting the app.
|
||||||
|
/// The source version is part of the key so a newer contract never inherits an older signature.
|
||||||
|
struct LeadSignedDocumentStore {
|
||||||
|
var directory: URL = URL.applicationSupportDirectory
|
||||||
|
.appending(path: "SignedLeadDocuments", directoryHint: .isDirectory)
|
||||||
|
|
||||||
|
private struct Record: Codable {
|
||||||
|
let leadID: String
|
||||||
|
let salesCode: String
|
||||||
|
let documentID: String
|
||||||
|
let fileID: String
|
||||||
|
let version: String
|
||||||
|
let filename: String
|
||||||
|
}
|
||||||
|
|
||||||
|
func save(url: URL, document: LeadDocument, leadID: String, salesCode: String) throws {
|
||||||
|
let record = Record(leadID: leadID, salesCode: salesCode, documentID: document.id,
|
||||||
|
fileID: document.file.id, version: document.file.version,
|
||||||
|
filename: url.lastPathComponent)
|
||||||
|
let data = try JSONEncoder().encode(record)
|
||||||
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||||
|
try data.write(to: directory.appending(path: url.deletingPathExtension().lastPathComponent + ".json"),
|
||||||
|
options: .atomic)
|
||||||
|
}
|
||||||
|
|
||||||
|
func savedURLs(documents: [LeadDocument], leadID: String, salesCode: String) -> [String: URL] {
|
||||||
|
guard let files = try? FileManager.default.contentsOfDirectory(
|
||||||
|
at: directory, includingPropertiesForKeys: nil
|
||||||
|
) else { return [:] }
|
||||||
|
var result: [String: URL] = [:]
|
||||||
|
for file in files where file.pathExtension == "json" {
|
||||||
|
guard let data = try? Data(contentsOf: file),
|
||||||
|
let record = try? JSONDecoder().decode(Record.self, from: data),
|
||||||
|
record.leadID == leadID, record.salesCode == salesCode,
|
||||||
|
documents.contains(where: {
|
||||||
|
$0.id == record.documentID && $0.file.id == record.fileID && $0.file.version == record.version
|
||||||
|
}) else { continue }
|
||||||
|
let url = directory.appending(path: record.filename)
|
||||||
|
if FileManager.default.fileExists(atPath: url.path) {
|
||||||
|
result[record.documentID] = url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
27
GasTankAR/Services/Signing/LeadSignedPDFUploadService.swift
Normal file
27
GasTankAR/Services/Signing/LeadSignedPDFUploadService.swift
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum LeadPDFUploadResult: Sendable {
|
||||||
|
case uploaded
|
||||||
|
case simulated
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
protocol LeadSignedPDFUploadService {
|
||||||
|
var isSimulation: Bool { get }
|
||||||
|
func upload(_ files: [URL], leadID: String, offerID: String, salesCode: String) async throws -> LeadPDFUploadResult
|
||||||
|
}
|
||||||
|
|
||||||
|
/// UI demonstration until the PDF upload endpoint is available. Sends no data.
|
||||||
|
struct StubLeadSignedPDFUploadService: LeadSignedPDFUploadService {
|
||||||
|
let isSimulation = true
|
||||||
|
var delay: Duration = .seconds(2)
|
||||||
|
|
||||||
|
func upload(_ files: [URL], leadID: String, offerID: String, salesCode: String) async throws -> LeadPDFUploadResult {
|
||||||
|
guard !files.isEmpty, files.allSatisfy({ FileManager.default.fileExists(atPath: $0.path) }) else {
|
||||||
|
throw PDFSigningError.invalidDocument
|
||||||
|
}
|
||||||
|
try await Task.sleep(for: delay)
|
||||||
|
try Task.checkCancellation()
|
||||||
|
return .simulated
|
||||||
|
}
|
||||||
|
}
|
||||||
26
GasTankAR/Services/Signing/PDFSigningService.swift
Normal file
26
GasTankAR/Services/Signing/PDFSigningService.swift
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import Foundation
|
||||||
|
import PencilKit
|
||||||
|
|
||||||
|
/// Coordinates are PDF points, measured from the bottom-left of the media box.
|
||||||
|
struct PDFSignaturePlacement: Equatable, Sendable {
|
||||||
|
let pageIndex: Int
|
||||||
|
let bounds: CGRect
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
protocol PDFSigningService {
|
||||||
|
func sign(source: URL, drawing: PKDrawing, placement: PDFSignaturePlacement) async throws -> URL
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PDFSigningError: LocalizedError {
|
||||||
|
case invalidDocument, emptySignature, invalidPlacement, saveFailed
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidDocument: "Die PDF konnte nicht gelesen werden."
|
||||||
|
case .emptySignature: "Bitte zuerst unterschreiben."
|
||||||
|
case .invalidPlacement: "Für dieses Dokument fehlt eine gültige Unterschriftsposition."
|
||||||
|
case .saveFailed: "Die unterschriebene PDF konnte nicht gespeichert werden."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
GasTankAR/Services/StubAuthenticationService.swift
Normal file
21
GasTankAR/Services/StubAuthenticationService.swift
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct StubAuthenticationService: AuthenticationService {
|
||||||
|
let simulatedError: LoginError?
|
||||||
|
let delay: Duration
|
||||||
|
|
||||||
|
init(simulatedError: LoginError? = nil, delay: Duration = .milliseconds(600)) {
|
||||||
|
self.simulatedError = simulatedError
|
||||||
|
self.delay = delay
|
||||||
|
}
|
||||||
|
|
||||||
|
func signIn(salesCode: String) async throws -> LoginSession {
|
||||||
|
try await Task.sleep(for: delay)
|
||||||
|
try Task.checkCancellation()
|
||||||
|
if let simulatedError {
|
||||||
|
throw simulatedError
|
||||||
|
}
|
||||||
|
// Demo employee until the authentication API supplies the profile.
|
||||||
|
return LoginSession(salesCode: salesCode, displayName: "M. Krämer")
|
||||||
|
}
|
||||||
|
}
|
||||||
71
GasTankAR/Services/TankThumbnailRenderer.swift
Normal file
71
GasTankAR/Services/TankThumbnailRenderer.swift
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import SceneKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// Renders the actual catalog model once per variant, shared by all project rows.
|
||||||
|
@MainActor
|
||||||
|
final class TankThumbnailRenderer {
|
||||||
|
static let shared = TankThumbnailRenderer()
|
||||||
|
private let cache = NSCache<NSString, UIImage>()
|
||||||
|
|
||||||
|
func image(for definition: TankDefinition) -> UIImage? {
|
||||||
|
let key = definition.modelPath as NSString
|
||||||
|
if let image = cache.object(forKey: key) { return image }
|
||||||
|
guard let url = Bundle.main.url(forResource: definition.modelResourceName,
|
||||||
|
withExtension: definition.modelFileExtension),
|
||||||
|
let source = try? SCNScene(url: url, options: [.convertToYUp: true]) else { return nil }
|
||||||
|
|
||||||
|
let scene = SCNScene()
|
||||||
|
let model = SCNNode()
|
||||||
|
for child in source.rootNode.childNodes { model.addChildNode(child.clone()) }
|
||||||
|
// Bundle scenes may carry studio lights; use one consistent thumbnail setup.
|
||||||
|
model.enumerateChildNodes { node, _ in
|
||||||
|
node.light = nil
|
||||||
|
node.camera = nil
|
||||||
|
}
|
||||||
|
// Underground catalog models need an additional quarter-turn after Y-up conversion.
|
||||||
|
if definition.placement == .belowGround {
|
||||||
|
model.eulerAngles.x = -.pi / 2
|
||||||
|
}
|
||||||
|
let container = SCNNode()
|
||||||
|
container.addChildNode(model)
|
||||||
|
scene.rootNode.addChildNode(container)
|
||||||
|
// Measure in the parent's coordinate system, including the corrected orientation.
|
||||||
|
let bounds = container.boundingBox
|
||||||
|
let center = SCNVector3((bounds.min.x + bounds.max.x) / 2,
|
||||||
|
(bounds.min.y + bounds.max.y) / 2,
|
||||||
|
(bounds.min.z + bounds.max.z) / 2)
|
||||||
|
let extent = SCNVector3(bounds.max.x - bounds.min.x,
|
||||||
|
bounds.max.y - bounds.min.y,
|
||||||
|
bounds.max.z - bounds.min.z)
|
||||||
|
let radius = max(sqrt(extent.x * extent.x + extent.y * extent.y + extent.z * extent.z) / 2, 0.01)
|
||||||
|
let camera = SCNNode()
|
||||||
|
camera.camera = SCNCamera()
|
||||||
|
camera.camera?.usesOrthographicProjection = true
|
||||||
|
camera.camera?.orthographicScale = Double(radius * 0.64)
|
||||||
|
camera.camera?.zNear = Double(radius * 0.01)
|
||||||
|
camera.camera?.zFar = Double(radius * 20)
|
||||||
|
camera.position = SCNVector3(center.x + radius * 0.65,
|
||||||
|
center.y + radius * 0.7, center.z + radius * 3)
|
||||||
|
camera.look(at: center)
|
||||||
|
scene.rootNode.addChildNode(camera)
|
||||||
|
let ambient = SCNNode()
|
||||||
|
ambient.light = SCNLight()
|
||||||
|
ambient.light?.type = .ambient
|
||||||
|
ambient.light?.intensity = 300
|
||||||
|
scene.rootNode.addChildNode(ambient)
|
||||||
|
let light = SCNNode()
|
||||||
|
light.light = SCNLight()
|
||||||
|
light.light?.type = .omni
|
||||||
|
light.light?.intensity = 800
|
||||||
|
light.position = SCNVector3(center.x - radius, center.y + radius * 3, center.z + radius * 2)
|
||||||
|
scene.rootNode.addChildNode(light)
|
||||||
|
|
||||||
|
let renderer = SCNRenderer(device: nil, options: nil)
|
||||||
|
renderer.scene = scene
|
||||||
|
renderer.pointOfView = camera
|
||||||
|
let image = renderer.snapshot(atTime: 0, with: CGSize(width: 600, height: 360),
|
||||||
|
antialiasingMode: .multisampling4X)
|
||||||
|
cache.setObject(image, forKey: key)
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
}
|
||||||
140
GasTankAR/Views/AR/Projects/ARProjectCreateView.swift
Normal file
140
GasTankAR/Views/AR/Projects/ARProjectCreateView.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
31
GasTankAR/Views/AR/Projects/ARProjectTankImage.swift
Normal file
31
GasTankAR/Views/AR/Projects/ARProjectTankImage.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
202
GasTankAR/Views/AR/Projects/ARProjectsSheet.swift
Normal file
202
GasTankAR/Views/AR/Projects/ARProjectsSheet.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
55
GasTankAR/Views/AR/Projects/ARProjectsViewModel.swift
Normal file
55
GasTankAR/Views/AR/Projects/ARProjectsViewModel.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -63,6 +63,7 @@ final class DocuframeViewModel {
|
|||||||
successText: String,
|
successText: String,
|
||||||
action: ([DocuframePhoto], String) async throws -> Void
|
action: ([DocuframePhoto], String) async throws -> Void
|
||||||
) async {
|
) async {
|
||||||
|
guard !isProcessing else { return }
|
||||||
let selected = photos.filter { selectedIDs.contains($0.persistentModelID) }
|
let selected = photos.filter { selectedIDs.contains($0.persistentModelID) }
|
||||||
guard !selected.isEmpty else {
|
guard !selected.isEmpty else {
|
||||||
result = .failure(message: DocuframeError.noPhotosSelected.localizedDescription)
|
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.
|
/// Bildet das Persistenz-Model auf ein persistenzunabhängiges Domänen-DTO ab.
|
||||||
private static func makeDTO(_ photo: ProjectPhoto) -> DocuframePhoto {
|
private static func makeDTO(_ photo: ProjectPhoto) -> DocuframePhoto {
|
||||||
DocuframePhoto(
|
DocuframePhoto(
|
||||||
imageData: photo.imageData,
|
imageData: photo.annotatedImageData ?? photo.imageData,
|
||||||
creationDate: photo.creationDate,
|
creationDate: photo.creationDate,
|
||||||
latitude: photo.latitude,
|
latitude: photo.latitude,
|
||||||
longitude: photo.longitude
|
longitude: photo.longitude
|
||||||
|
|||||||
@ -60,8 +60,8 @@ struct GalleryView: View {
|
|||||||
PhotoViewer(
|
PhotoViewer(
|
||||||
photo: photo,
|
photo: photo,
|
||||||
photos: project.photos.sorted { $0.creationDate > $1.creationDate }
|
photos: project.photos.sorted { $0.creationDate > $1.creationDate }
|
||||||
) {
|
) { selected in
|
||||||
delete(photo)
|
delete(selected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -158,9 +158,10 @@ private struct DrawingStroke {
|
|||||||
var isEraser: Bool
|
var isEraser: Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct PhotoViewer: View {
|
struct PhotoViewer: View {
|
||||||
let photos: [ProjectPhoto]
|
let photos: [ProjectPhoto]
|
||||||
let onDelete: () -> Void
|
let onDelete: (ProjectPhoto) throws -> Void
|
||||||
|
let usesNavigationStack: Bool
|
||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
@ -172,19 +173,36 @@ private struct PhotoViewer: View {
|
|||||||
@State private var drawingColor = Color.red
|
@State private var drawingColor = Color.red
|
||||||
@State private var drawingWidth: Double = 0.012
|
@State private var drawingWidth: Double = 0.012
|
||||||
@State private var imageRevision = 0
|
@State private var imageRevision = 0
|
||||||
|
@State private var deletionError: String?
|
||||||
|
|
||||||
private var currentPhoto: ProjectPhoto {
|
private var currentPhoto: ProjectPhoto {
|
||||||
photos[currentIndex]
|
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.photos = photos
|
||||||
self.onDelete = onDelete
|
self.onDelete = onDelete
|
||||||
|
self.usesNavigationStack = usesNavigationStack
|
||||||
_currentIndex = State(initialValue: photos.firstIndex(of: photo) ?? 0)
|
_currentIndex = State(initialValue: photos.firstIndex(of: photo) ?? 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
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) {
|
TabView(selection: $currentIndex) {
|
||||||
ForEach(photos.indices, id: \.self) { index in
|
ForEach(photos.indices, id: \.self) { index in
|
||||||
photoLayout(for: photos[index])
|
photoLayout(for: photos[index])
|
||||||
@ -197,7 +215,7 @@ private struct PhotoViewer: View {
|
|||||||
ToolbarItem(placement: .topBarLeading) {
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
if isDrawing {
|
if isDrawing {
|
||||||
EmptyView()
|
EmptyView()
|
||||||
} else {
|
} else if usesNavigationStack {
|
||||||
Button("Fertig") { dismiss() }
|
Button("Fertig") { dismiss() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -213,8 +231,10 @@ private struct PhotoViewer: View {
|
|||||||
.accessibilityLabel("Zeichnung speichern")
|
.accessibilityLabel("Zeichnung speichern")
|
||||||
} else {
|
} else {
|
||||||
Button("Löschen", systemImage: "trash", role: .destructive) {
|
Button("Löschen", systemImage: "trash", role: .destructive) {
|
||||||
onDelete()
|
do {
|
||||||
|
try onDelete(currentPhoto)
|
||||||
dismiss()
|
dismiss()
|
||||||
|
} catch { deletionError = error.localizedDescription }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -251,7 +271,6 @@ private struct PhotoViewer: View {
|
|||||||
}
|
}
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private func photoLayout(for photo: ProjectPhoto) -> some View {
|
private func photoLayout(for photo: ProjectPhoto) -> some View {
|
||||||
GeometryReader { geometry in
|
GeometryReader { geometry in
|
||||||
@ -619,6 +638,6 @@ private struct PhotoLocationCard: View {
|
|||||||
|
|
||||||
#Preview("Foto-Detail") {
|
#Preview("Foto-Detail") {
|
||||||
let photo = PreviewSupport.samplePhoto()
|
let photo = PreviewSupport.samplePhoto()
|
||||||
PhotoViewer(photo: photo, photos: [photo]) {}
|
PhotoViewer(photo: photo, photos: [photo]) { _ in }
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
47
GasTankAR/Views/Leads/Detail/LeadARProjectsSheet.swift
Normal file
47
GasTankAR/Views/Leads/Detail/LeadARProjectsSheet.swift
Normal file
@ -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 ?? "") }
|
||||||
|
}
|
||||||
|
}
|
||||||
57
GasTankAR/Views/Leads/Detail/LeadContactSection.swift
Normal file
57
GasTankAR/Views/Leads/Detail/LeadContactSection.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
74
GasTankAR/Views/Leads/Detail/LeadDetailHeaderView.swift
Normal file
74
GasTankAR/Views/Leads/Detail/LeadDetailHeaderView.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
110
GasTankAR/Views/Leads/Detail/LeadDetailPresentation.swift
Normal file
110
GasTankAR/Views/Leads/Detail/LeadDetailPresentation.swift
Normal file
@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
98
GasTankAR/Views/Leads/Detail/LeadDetailScreen.swift
Normal file
98
GasTankAR/Views/Leads/Detail/LeadDetailScreen.swift
Normal file
@ -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("")
|
||||||
|
}
|
||||||
|
}
|
||||||
53
GasTankAR/Views/Leads/Detail/LeadDetailSkeletonView.swift
Normal file
53
GasTankAR/Views/Leads/Detail/LeadDetailSkeletonView.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
68
GasTankAR/Views/Leads/Detail/LeadDetailView.swift
Normal file
68
GasTankAR/Views/Leads/Detail/LeadDetailView.swift
Normal file
@ -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<Header: View, Left: View, Right: View>: 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
106
GasTankAR/Views/Leads/Detail/LeadDetailViewModel.swift
Normal file
106
GasTankAR/Views/Leads/Detail/LeadDetailViewModel.swift
Normal file
@ -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<String> = []
|
||||||
|
var expandedGroups: Set<String> = []
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
GasTankAR/Views/Leads/Detail/LeadFileViewModel.swift
Normal file
33
GasTankAR/Views/Leads/Detail/LeadFileViewModel.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
91
GasTankAR/Views/Leads/Detail/LeadLocationSection.swift
Normal file
91
GasTankAR/Views/Leads/Detail/LeadLocationSection.swift
Normal file
@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
192
GasTankAR/Views/Leads/Detail/LeadOffersSection.swift
Normal file
192
GasTankAR/Views/Leads/Detail/LeadOffersSection.swift
Normal file
@ -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<Content: View>: 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
62
GasTankAR/Views/Leads/Detail/LeadPDFPreview.swift
Normal file
62
GasTankAR/Views/Leads/Detail/LeadPDFPreview.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
316
GasTankAR/Views/Leads/Detail/LeadPhotoGallerySection.swift
Normal file
316
GasTankAR/Views/Leads/Detail/LeadPhotoGallerySection.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
50
GasTankAR/Views/Leads/Overview/BookmarkedLeadCardView.swift
Normal file
50
GasTankAR/Views/Leads/Overview/BookmarkedLeadCardView.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
37
GasTankAR/Views/Leads/Overview/BookmarkedLeadsSection.swift
Normal file
37
GasTankAR/Views/Leads/Overview/BookmarkedLeadsSection.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
GasTankAR/Views/Leads/Overview/LeadCardFlightView.swift
Normal file
23
GasTankAR/Views/Leads/Overview/LeadCardFlightView.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
44
GasTankAR/Views/Leads/Overview/LeadCardGrid.swift
Normal file
44
GasTankAR/Views/Leads/Overview/LeadCardGrid.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
75
GasTankAR/Views/Leads/Overview/LeadCardView.swift
Normal file
75
GasTankAR/Views/Leads/Overview/LeadCardView.swift
Normal file
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
62
GasTankAR/Views/Leads/Overview/LeadOverviewHeaderView.swift
Normal file
62
GasTankAR/Views/Leads/Overview/LeadOverviewHeaderView.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
69
GasTankAR/Views/Leads/Overview/LeadOverviewStateView.swift
Normal file
69
GasTankAR/Views/Leads/Overview/LeadOverviewStateView.swift
Normal file
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
223
GasTankAR/Views/Leads/Overview/LeadOverviewView.swift
Normal file
223
GasTankAR/Views/Leads/Overview/LeadOverviewView.swift
Normal file
@ -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: {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
173
GasTankAR/Views/Leads/Overview/LeadOverviewViewModel.swift
Normal file
173
GasTankAR/Views/Leads/Overview/LeadOverviewViewModel.swift
Normal file
@ -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<String> = []
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
53
GasTankAR/Views/Leads/Overview/LeadPresentation.swift
Normal file
53
GasTankAR/Views/Leads/Overview/LeadPresentation.swift
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
51
GasTankAR/Views/Leads/Overview/LeadResultsSection.swift
Normal file
51
GasTankAR/Views/Leads/Overview/LeadResultsSection.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
35
GasTankAR/Views/Leads/Overview/LeadSearchField.swift
Normal file
35
GasTankAR/Views/Leads/Overview/LeadSearchField.swift
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct LeadSearchField: View {
|
||||||
|
@Binding var text: String
|
||||||
|
var isFocused: FocusState<Bool>.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))
|
||||||
|
}
|
||||||
|
}
|
||||||
73
GasTankAR/Views/Leads/Overview/LeadStatusView.swift
Normal file
73
GasTankAR/Views/Leads/Overview/LeadStatusView.swift
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
52
GasTankAR/Views/Leads/Shared/LeadDocumentPreview.swift
Normal file
52
GasTankAR/Views/Leads/Shared/LeadDocumentPreview.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
72
GasTankAR/Views/Leads/Shared/LeadPDFView.swift
Normal file
72
GasTankAR/Views/Leads/Shared/LeadPDFView.swift
Normal file
@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
44
GasTankAR/Views/Leads/Signing/LeadSignatureCanvas.swift
Normal file
44
GasTankAR/Views/Leads/Signing/LeadSignatureCanvas.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct LeadSigningDocumentListView: View {
|
||||||
|
let documents: [LeadDocument]
|
||||||
|
let currentID: String?
|
||||||
|
let signedIDs: Set<String>
|
||||||
|
|
||||||
|
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 : [])
|
||||||
|
}
|
||||||
|
}
|
||||||
28
GasTankAR/Views/Leads/Signing/LeadSigningFooterView.swift
Normal file
28
GasTankAR/Views/Leads/Signing/LeadSigningFooterView.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
34
GasTankAR/Views/Leads/Signing/LeadSigningHeaderView.swift
Normal file
34
GasTankAR/Views/Leads/Signing/LeadSigningHeaderView.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
42
GasTankAR/Views/Leads/Signing/LeadSigningPreviewView.swift
Normal file
42
GasTankAR/Views/Leads/Signing/LeadSigningPreviewView.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
156
GasTankAR/Views/Leads/Signing/LeadSigningSheet.swift
Normal file
156
GasTankAR/Views/Leads/Signing/LeadSigningSheet.swift
Normal file
@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
65
GasTankAR/Views/Leads/Signing/LeadSigningSignatureView.swift
Normal file
65
GasTankAR/Views/Leads/Signing/LeadSigningSignatureView.swift
Normal file
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
145
GasTankAR/Views/Leads/Signing/LeadSigningViewModel.swift
Normal file
145
GasTankAR/Views/Leads/Signing/LeadSigningViewModel.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
289
GasTankAR/Views/LoginView.swift
Normal file
289
GasTankAR/Views/LoginView.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
47
GasTankAR/Views/LoginViewModel.swift
Normal file
47
GasTankAR/Views/LoginViewModel.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
187
GasTankARTests/ARProjectsTests.swift
Normal file
187
GasTankARTests/ARProjectsTests.swift
Normal file
@ -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<Project>()) == 2)
|
||||||
|
#expect(try ModelContext(container).fetchCount(FetchDescriptor<LeadARProjectLink>()) == 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<Project>()) == 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<Project>()) == 0)
|
||||||
|
#expect(try ModelContext(container).fetchCount(FetchDescriptor<LeadARProjectLink>()) == 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<Project>()) == 2)
|
||||||
|
#expect(try ModelContext(container).fetchCount(FetchDescriptor<LeadARProjectLink>()) == 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<Project>()) == 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" })
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,9 +12,10 @@ import SwiftData
|
|||||||
|
|
||||||
// MARK: - Test-Doubles
|
// 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.
|
/// der Aufrufe zählt und wahlweise einen Fehler wirft.
|
||||||
private actor MockDocuframeService: DocuframeService {
|
@MainActor
|
||||||
|
private final class MockDocuframeService: DocuframeService {
|
||||||
enum Behavior: Sendable {
|
enum Behavior: Sendable {
|
||||||
case success
|
case success
|
||||||
case failure(DocuframeError)
|
case failure(DocuframeError)
|
||||||
|
|||||||
146
GasTankARTests/LeadDetailTests.swift
Normal file
146
GasTankARTests/LeadDetailTests.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
434
GasTankARTests/LeadTests.swift
Normal file
434
GasTankARTests/LeadTests.swift
Normal file
@ -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<Void, Never>)] = []
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
137
GasTankARTests/LoginTests.swift
Normal file
137
GasTankARTests/LoginTests.swift
Normal file
@ -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<LoginSession, Never>?
|
||||||
|
private(set) var callCount = 0
|
||||||
|
|
||||||
|
func execute(salesCode: String) async throws -> LoginSession {
|
||||||
|
callCount += 1
|
||||||
|
return await withCheckedContinuation { continuation = $0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
266
GasTankARTests/PDFSigningTests.swift
Normal file
266
GasTankARTests/PDFSigningTests.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user