import Foundation /// Owns shared daily rates and calculation state for one launcher lifetime. @MainActor public final class CurrencyCalculation { public private(set) var result: CalculatorResult? public private(set) var isCurrency = false public var changed: (() -> Void)? private let fetch: @MainActor () async throws -> CurrencyRates private let now: () -> Date private let calendar: Calendar private var query = "" private var rates: CurrencyRates? private var fetchedAt: Date? private var task: Task? private var error: String? private var failureQuery: String? public init( now: @escaping () -> Date = Date.init, calendar: Calendar = .current, fetch: @escaping @MainActor () async throws -> CurrencyRates = { try await CurrencyAPI.fetch() } ) { self.now = now self.calendar = calendar self.fetch = fetch } deinit { task?.cancel() } public func update(_ query: String) { self.query = query if failureQuery != query { error = nil failureQuery = nil } evaluate(allowingFetch: true) } private func evaluate(allowingFetch: Bool) { isCurrency = Calculator.isCurrencyQuery(query, currencyRates: rates) let stale = fetchedAt.map { !calendar.isDate($0, inSameDayAs: now()) } ?? true if allowingFetch, isCurrency, stale, error == nil, task == nil { refresh() } result = Calculator.evaluate(query, currencyRates: rates) if isCurrency, let error { let cached = rates.map { " Cached snapshot \($0.date) is not used." } ?? "" result = CalculatorResult(displayValue: "Currency rates unavailable", copyValue: nil, detail: "Daily rates: \(error). Change the query to retry.\(cached)") } else if isCurrency, rates != nil, let answer = result { let cacheStatus = stale ? (task == nil ? "Stale cached daily rates" : "Stale cache; refreshing daily rates…") : "Cached daily rates" result = CalculatorResult(displayValue: answer.displayValue, copyValue: answer.copyValue, detail: "\(answer.detail) · \(cacheStatus)") } } private func refresh() { let fetch = self.fetch task = Task { [weak self] in let response: Result do { response = .success(try await fetch()) } catch { response = .failure(error) } guard !Task.isCancelled, let self else { return } self.task = nil switch response { case .success(let rates): self.rates = rates self.fetchedAt = self.now() self.error = nil self.failureQuery = nil case .failure(let error): self.error = error.localizedDescription self.failureQuery = self.query } self.evaluate(allowingFetch: false) self.changed?() } } }