Add lead workflows, PDF signing, AR projects and photo gallery integration
This commit is contained in:
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user