feat: add daily fiat and crypto currency conversion

This commit is contained in:
2026-10-13 21:31:14 +02:00
parent a57cbc219c
commit d3f9ec92d7
5 changed files with 459 additions and 13 deletions
+69 -7
View File
@@ -13,6 +13,12 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
private var applications: [Application] = []
private var results: [Application] = []
private var calculation: CalculatorResult?
private var isCurrencyCalculation = false
private var currencyRates: CurrencyRates?
private var currencyFetchedAt: Date?
private var currencyTask: Task<Void, Never>?
private var currencyError: String?
private var currencyFailureQuery: String?
private var locationLabels: [URL: String] = [:]
private var discoveryTask: Task<Void, Never>?
private var isLaunching = false
@@ -73,6 +79,10 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
fatalError("Conjure creates its window programmatically")
}
deinit {
currencyTask?.cancel()
}
func present() {
if launcherFrameBeforeReview != nil {
showWindow(nil)
@@ -181,7 +191,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
let searchFont = NSFont.systemFont(ofSize: 20)
searchField.font = searchFont
searchField.placeholderAttributedString = NSAttributedString(
string: "Search apps, calculate, or convert units…",
string: "Search apps, calculate, or convert…",
attributes: [.foregroundColor: SunsetPalette.secondaryText, .font: searchFont]
)
searchField.textColor = SunsetPalette.text
@@ -195,7 +205,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
searchField.sendsSearchStringImmediately = true
searchField.sendsWholeSearchString = false
searchField.delegate = self
searchField.setAccessibilityLabel("Search applications, calculate, or convert units")
searchField.setAccessibilityLabel("Search applications, calculate, or convert units and currencies")
let symbol = NSImageView()
symbol.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: nil)
@@ -348,6 +358,28 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
}
}
private func refreshCurrencyRates() {
guard currencyTask == nil else { return }
currencyTask = Task { [weak self] in
let result: Result<CurrencyRates, any Error>
do { result = .success(try await CurrencyAPI.fetch()) }
catch { result = .failure(error) }
guard !Task.isCancelled, let self else { return }
self.currencyTask = nil
switch result {
case .success(let rates):
self.currencyRates = rates
self.currencyFetchedAt = Date()
self.currencyError = nil
self.currencyFailureQuery = nil
case .failure(let error):
self.currencyError = error.localizedDescription
self.currencyFailureQuery = self.searchField.stringValue
}
self.updateResults(preservingSelection: true, allowingCurrencyFetch: false)
}
}
private func updateLocationLabels() {
var nameCounts: [String: Int] = [:]
nameCounts.reserveCapacity(applications.count)
@@ -370,10 +402,37 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
}
}
private func updateResults(preservingSelection: Bool = false) {
private func updateResults(preservingSelection: Bool = false, allowingCurrencyFetch: Bool = true) {
let selectedURL = preservingSelection && results.indices.contains(tableView.selectedRow)
? results[tableView.selectedRow].id : nil
calculation = Calculator.evaluate(searchField.stringValue)
let query = searchField.stringValue
if currencyFailureQuery != query {
currencyError = nil
currencyFailureQuery = nil
}
isCurrencyCalculation = Calculator.isCurrencyQuery(query, currencyRates: currencyRates)
let needsCurrencyRefresh = isCurrencyCalculation && (currencyFetchedAt.map {
!Calendar.current.isDate($0, inSameDayAs: Date())
} ?? true)
if allowingCurrencyFetch, needsCurrencyRefresh, currencyError == nil {
refreshCurrencyRates()
}
calculation = Calculator.evaluate(query, currencyRates: currencyRates)
if isCurrencyCalculation, let currencyError {
let cached = currencyRates.map { " Cached snapshot \($0.date) is not used." } ?? ""
calculation = CalculatorResult(
displayValue: "Currency rates unavailable", copyValue: nil,
detail: "Daily rates: \(currencyError). Change the query to retry.\(cached)"
)
} else if isCurrencyCalculation, currencyRates != nil, let answer = calculation {
let cacheStatus = needsCurrencyRefresh
? (currencyTask == nil ? "Stale cached daily rates" : "Stale cache; refreshing daily rates…")
: "Cached daily rates"
calculation = CalculatorResult(
displayValue: answer.displayValue, copyValue: answer.copyValue,
detail: "\(answer.detail) · \(cacheStatus)"
)
}
if calculation != nil {
results = []
} else if searchField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
@@ -381,8 +440,10 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
} else {
results = history.results(for: searchField.stringValue, in: applications)
}
sectionLabel.stringValue = calculation == nil ? "Applications" : "Calculator"
tableView.setAccessibilityLabel(calculation == nil ? "Applications" : "Calculator result")
sectionLabel.stringValue = isCurrencyCalculation ? "Daily currency rates"
: calculation == nil ? "Applications" : "Calculator"
tableView.setAccessibilityLabel(isCurrencyCalculation ? "Daily currency conversion result"
: calculation == nil ? "Applications" : "Calculator result")
tableView.reloadData()
if resultCount > 0 {
let row = selectedURL.flatMap { url in results.firstIndex { $0.id == url } } ?? 0
@@ -457,7 +518,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
if let calculation {
cell.textField?.stringValue = calculation.displayValue
cell.locationLabel.isHidden = true
cell.kindLabel.stringValue = calculation.copyValue == nil ? "Error" : "Calculator"
cell.kindLabel.stringValue = isCurrencyCalculation ? "Daily rates"
: calculation.copyValue == nil ? "Error" : "Calculator"
cell.imageView?.image = NSImage(systemSymbolName: "equal", accessibilityDescription: nil)?
.withSymbolConfiguration(.init(pointSize: LauncherRowView.iconSize, weight: .regular))
cell.imageView?.contentTintColor = SunsetPalette.accent