174 lines
6.3 KiB
Swift
174 lines
6.3 KiB
Swift
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
|
|
}
|
|
}
|