From 58d862161e76544aae8353da985aa7a2067ed5e7 Mon Sep 17 00:00:00 2001 From: TZGyn Date: Tue, 29 Sep 2026 21:02:45 +0800 Subject: [PATCH] Show each wallet's balance in USD --- apps/ios/Koins/AppModel.swift | 26 ++++++++ apps/ios/Koins/Support/PriceService.swift | 79 +++++++++++++++++++++++ apps/ios/Koins/Views/DashboardViews.swift | 5 ++ apps/ios/Koins/Views/RootView.swift | 5 ++ 4 files changed, 115 insertions(+) create mode 100644 apps/ios/Koins/Support/PriceService.swift diff --git a/apps/ios/Koins/AppModel.swift b/apps/ios/Koins/AppModel.swift index 96bb202..2fa6a39 100644 --- a/apps/ios/Koins/AppModel.swift +++ b/apps/ios/Koins/AppModel.swift @@ -15,6 +15,8 @@ final class AppModel: ObservableObject { @Published var moneroReady = false @Published var moneroAddress: String? @Published var moneroAccounts: [MoneroAccount] = [] + @Published var prices = PriceService.Prices() + /// Whether the app is behind the biometric lock screen right now. @Published var locked = false @@ -53,6 +55,28 @@ final class AppModel: ObservableObject { var symbol: String { chain.symbol } + // MARK: - Fiat display + + private static let fiatFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.currencyCode = "USD" + formatter.locale = Locale(identifier: "en_US") + return formatter + }() + + /// The current chain's balance in USD, when a price is known. + var fiatBalance: String? { + let price = chain == .xrp ? prices.xrp : prices.xmr + guard let price, let amount = Decimal(string: balance.total) else { return nil } + let usd = amount * Decimal(price) + return Self.fiatFormatter.string(from: usd as NSDecimalNumber) + } + + private func refreshPrices() async { + prices = await PriceService.shared.prices() + } + // MARK: - Biometric lock func authenticate() async { @@ -112,6 +136,7 @@ final class AppModel: ObservableObject { showCached(chain) await refresh(force: true) startAutoRefresh() + Task { await refreshPrices() } } private func adoptMoneroWallet(named name: String) async { @@ -263,6 +288,7 @@ final class AppModel: ObservableObject { refreshToken = token let requestChain = chain errorMessage = nil + await refreshPrices() if chain == .xrp, let wallet = xrpWallet { do { async let balanceCall = XRPWalletService.shared.balance(address: wallet.address) diff --git a/apps/ios/Koins/Support/PriceService.swift b/apps/ios/Koins/Support/PriceService.swift new file mode 100644 index 0000000..9ff12fc --- /dev/null +++ b/apps/ios/Koins/Support/PriceService.swift @@ -0,0 +1,79 @@ +import Foundation + +/// USD prices for the two tickers the wallet supports, fetched from a public +/// price API and cached briefly so a screen does not fetch on every refresh. +final class PriceService { + static let shared = PriceService() + + struct Prices: Codable { + var xrp: Double? + var xmr: Double? + var fetchedAt: Date? + } + + private static let cacheLifetime: TimeInterval = 60 + private var memory: Prices? + private var inFlight: Task? + + private init() {} + + func prices() async -> Prices { + if let memory, let fetchedAt = memory.fetchedAt, + Date().timeIntervalSince(fetchedAt) < Self.cacheLifetime { + return memory + } + if let inFlight { return await inFlight.value } + + let task = Task { [weak self] in + guard let self else { return Prices(fetchedAt: nil) } + let prices = await self.fetch() + self.memory = prices + return prices + } + inFlight = task + let result = await task.value + inFlight = nil + return result + } + + private func fetch() async -> Prices { + if let prices = try? await fromCoinGecko() { return prices } + return (try? await fromKraken()) ?? Prices(fetchedAt: nil) + } + + private func fromCoinGecko() async throws -> Prices { + guard let url = URL(string: "https://api.coingecko.com/api/v3/simple/price?ids=monero,ripple&vs_currencies=usd") else { + throw XRPError.network("Invalid price endpoint") + } + let (data, response) = try await URLSession.shared.data(from: url) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw XRPError.network("CoinGecko request failed") + } + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:] + return Prices( + xrp: (json["ripple"] as? [String: Any]).flatMap { ($0["usd"] as? NSNumber)?.doubleValue }, + xmr: (json["monero"] as? [String: Any]).flatMap { ($0["usd"] as? NSNumber)?.doubleValue }, + fetchedAt: Date()) + } + + private func fromKraken() async throws -> Prices { + guard let url = URL(string: "https://api.kraken.com/0/public/Ticker?pair=XRPUSD,XMRUSD") else { + throw XRPError.network("Invalid price endpoint") + } + let (data, response) = try await URLSession.shared.data(from: url) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw XRPError.network("Kraken request failed") + } + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:] + let result = json["result"] as? [String: Any] ?? [:] + + func lastTrade(_ key: String) -> Double? { + (result[key] as? [String: Any]) + .flatMap { entry in (entry["c"] as? [Any])?.first } + .flatMap { ($0 as? String).flatMap(Double.init) } + } + return Prices(xrp: lastTrade("XXRPZUSD") ?? lastTrade("XRPUSD"), + xmr: lastTrade("XXMRZUSD") ?? lastTrade("XMRUSD"), + fetchedAt: Date()) + } +} \ No newline at end of file diff --git a/apps/ios/Koins/Views/DashboardViews.swift b/apps/ios/Koins/Views/DashboardViews.swift index db61115..3839baf 100644 --- a/apps/ios/Koins/Views/DashboardViews.swift +++ b/apps/ios/Koins/Views/DashboardViews.swift @@ -24,6 +24,11 @@ struct DashboardView: View { Text("\(model.balance.total) \(model.symbol)") .font(.system(size: 32, weight: .bold, design: .rounded)) .foregroundStyle(Theme.text) + if let fiat = model.fiatBalance { + Text("\(fiat)") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(Theme.muted) + } if model.chain == .xmr, model.balance.unlocked != model.balance.total { Text("\(model.balance.unlocked) \(model.symbol) spendable now") .font(.system(size: 11)) diff --git a/apps/ios/Koins/Views/RootView.swift b/apps/ios/Koins/Views/RootView.swift index 8cf8b87..545aeb1 100644 --- a/apps/ios/Koins/Views/RootView.swift +++ b/apps/ios/Koins/Views/RootView.swift @@ -183,6 +183,11 @@ struct HomeView: View { Text("\(model.balance.total) \(model.symbol)") .font(.system(size: 30, weight: .bold, design: .rounded)) .foregroundStyle(Theme.text) + if let fiat = model.fiatBalance { + Text("\(fiat)") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(Theme.muted) + } if model.chain == .xmr, model.balance.unlocked != model.balance.total { Text("\(model.balance.unlocked) \(model.symbol) unlocked") .font(.system(size: 11))