From 994ba5ed6b7f59d1b84869e0ccc4911b30089384 Mon Sep 17 00:00:00 2001 From: TZGyn Date: Thu, 1 Oct 2026 22:05:19 +0800 Subject: [PATCH] Give each Monero account a swipeable card with its balance and QR --- apps/ios/Koins/AppModel.swift | 40 ++++++---- apps/ios/Koins/Support/PriceService.swift | 67 +++++++++++----- apps/ios/Koins/Views/RootView.swift | 94 +++++++++++++++++++---- apps/ios/Koins/Views/SettingsView.swift | 38 ++++++++- 4 files changed, 189 insertions(+), 50 deletions(-) diff --git a/apps/ios/Koins/AppModel.swift b/apps/ios/Koins/AppModel.swift index 2fa6a39..32fa10a 100644 --- a/apps/ios/Koins/AppModel.swift +++ b/apps/ios/Koins/AppModel.swift @@ -15,7 +15,7 @@ final class AppModel: ObservableObject { @Published var moneroReady = false @Published var moneroAddress: String? @Published var moneroAccounts: [MoneroAccount] = [] - @Published var prices = PriceService.Prices() + @Published var prices = PriceService.Prices(currency: "USD") /// Whether the app is behind the biometric lock screen right now. @Published var locked = false @@ -57,24 +57,38 @@ final class AppModel: ObservableObject { // MARK: - Fiat display - private static let fiatFormatter: NumberFormatter = { + /// The fiat currency the wallet shows; persisted, and changing it refetches + /// the price cache. + var fiatCurrency: String { + get { UserDefaults.standard.string(forKey: "koins.fiatCurrency") ?? "USD" } + set { + UserDefaults.standard.set(newValue, forKey: "koins.fiatCurrency") + objectWillChange.send() + Task { await refreshPrices() } + } + } + + /// The current chain's balance in the chosen fiat currency, when a price is + /// known. + var fiatBalance: String? { + fiatValue(of: balance.total, chain: chain) + } + + /// Converts a chain amount into the chosen fiat currency, when a price is + /// known. + func fiatValue(of amount: String, chain: Chain) -> String? { + let price = chain == .xrp ? prices.xrp : prices.xmr + guard let price, let value = Decimal(string: amount) else { return nil } + let fiat = value * Decimal(price) let formatter = NumberFormatter() formatter.numberStyle = .currency - formatter.currencyCode = "USD" + formatter.currencyCode = fiatCurrency 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) + return formatter.string(from: fiat as NSDecimalNumber) } private func refreshPrices() async { - prices = await PriceService.shared.prices() + prices = await PriceService.shared.prices(currency: fiatCurrency) } // MARK: - Biometric lock diff --git a/apps/ios/Koins/Support/PriceService.swift b/apps/ios/Koins/Support/PriceService.swift index 9ff12fc..b5e8fcc 100644 --- a/apps/ios/Koins/Support/PriceService.swift +++ b/apps/ios/Koins/Support/PriceService.swift @@ -1,34 +1,49 @@ 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. +/// Fiat prices for the two tickers the wallet supports, fetched from a public +/// price API. Cached briefly in memory and in UserDefaults so screens do not +/// refetch on every refresh, and so the currency conversion shows up instantly +/// on a cold start. final class PriceService { static let shared = PriceService() struct Prices: Codable { var xrp: Double? var xmr: Double? + var currency: String var fetchedAt: Date? } private static let cacheLifetime: TimeInterval = 60 + private static let defaultsKey = "koins.prices" + private var memory: Prices? private var inFlight: Task? - private init() {} + private init() { + if let data = UserDefaults.standard.data(forKey: Self.defaultsKey), + let saved = try? JSONDecoder().decode(Prices.self, from: data) { + memory = saved + } + } - func prices() async -> Prices { - if let memory, let fetchedAt = memory.fetchedAt, + func prices(currency: String) async -> Prices { + if let memory, + memory.currency == currency, + 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 + guard let self else { return Prices(currency: currency, fetchedAt: nil) } + let fetched = await self.fetch(currency: currency) + self.memory = fetched + if let data = try? JSONEncoder().encode(fetched) { + UserDefaults.standard.set(data, forKey: Self.defaultsKey) + } + return fetched } inFlight = task let result = await task.value @@ -36,13 +51,14 @@ final class PriceService { return result } - private func fetch() async -> Prices { - if let prices = try? await fromCoinGecko() { return prices } - return (try? await fromKraken()) ?? Prices(fetchedAt: nil) + private func fetch(currency: String) async -> Prices { + if let prices = try? await fromCoinGecko(currency: currency) { return prices } + return (try? await fromKraken(currency: currency)) ?? Prices(currency: currency, 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 { + private func fromCoinGecko(currency: String) async throws -> Prices { + let code = currency.lowercased() + guard let url = URL(string: "https://api.coingecko.com/api/v3/simple/price?ids=monero,ripple&vs_currencies=\(code)") else { throw XRPError.network("Invalid price endpoint") } let (data, response) = try await URLSession.shared.data(from: url) @@ -51,13 +67,23 @@ final class PriceService { } 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 }, + xrp: (json["ripple"] as? [String: Any]).flatMap { ($0[code] as? NSNumber)?.doubleValue }, + xmr: (json["monero"] as? [String: Any]).flatMap { ($0[code] as? NSNumber)?.doubleValue }, + currency: currency, fetchedAt: Date()) } - private func fromKraken() async throws -> Prices { - guard let url = URL(string: "https://api.kraken.com/0/public/Ticker?pair=XRPUSD,XMRUSD") else { + /// Kraken only lists USD and EUR for both assets; anything else just yields + /// no price rather than a wrong one. + private func fromKraken(currency: String) async throws -> Prices { + let supported: [String: (xrp: String, xmr: String)] = [ + "USD": ("XXRPZUSD", "XXMRZUSD"), + "EUR": ("XXRPZEUR", "XXMRZEUR"), + ] + guard let pairs = supported[currency] else { + throw XRPError.network("Kraken has no \(currency) pairs") + } + guard let url = URL(string: "https://api.kraken.com/0/public/Ticker?pair=\(pairs.xrp),\(pairs.xmr)") else { throw XRPError.network("Invalid price endpoint") } let (data, response) = try await URLSession.shared.data(from: url) @@ -72,8 +98,9 @@ final class PriceService { .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"), + return Prices(xrp: lastTrade(pairs.xrp), + xmr: lastTrade(pairs.xmr), + currency: currency, fetchedAt: Date()) } } \ No newline at end of file diff --git a/apps/ios/Koins/Views/RootView.swift b/apps/ios/Koins/Views/RootView.swift index 545aeb1..979cfd7 100644 --- a/apps/ios/Koins/Views/RootView.swift +++ b/apps/ios/Koins/Views/RootView.swift @@ -90,6 +90,8 @@ struct HomeView: View { @State private var showDeleteConfirm = false @State private var selectedTransaction: TransactionRecord? @State private var showSettings = false + @State private var activeAccount = 0 + @State private var qrAddress: AddressQR? var body: some View { VStack(spacing: 18) { @@ -115,6 +117,9 @@ struct HomeView: View { symbol: model.symbol, chain: model.chain) { selectedTransaction = nil } } + .sheet(item: $qrAddress) { item in + AddressQRSheet(title: item.title, address: item.address) { qrAddress = nil } + } .sheet(isPresented: $showSettings) { SettingsView() .environmentObject(model) @@ -176,26 +181,38 @@ struct HomeView: View { } } - VStack(alignment: .leading, spacing: 4) { - Text("Balance") - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(Theme.muted) - 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.moneroAccounts.isEmpty { + // Cake Wallet style: one card per account, swiped horizontally. + TabView(selection: $activeAccount) { + ForEach(model.moneroAccounts) { account in + moneroAccountCard(account) + .tag(account.index) + } } - if model.chain == .xmr, model.balance.unlocked != model.balance.total { - Text("\(model.balance.unlocked) \(model.symbol) unlocked") - .font(.system(size: 11)) + .tabViewStyle(.page(indexDisplayMode: .always)) + .frame(height: 208) + } else { + VStack(alignment: .leading, spacing: 4) { + Text("Balance") + .font(.system(size: 11, weight: .medium)) .foregroundStyle(Theme.muted) + 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)) + .foregroundStyle(Theme.muted) + } } - } - AddressRow(address: model.displayAddress) + AddressRow(address: model.displayAddress) + } if model.chain == .xmr, let status = model.syncStatus { SyncProgress(status: status, eta: model.syncEta) @@ -270,6 +287,51 @@ struct HomeView: View { } } + private func moneroAccountCard(_ account: MoneroAccount) -> some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text(account.title) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Theme.muted) + Spacer() + Text("Account \(account.index + 1) of \(model.moneroAccounts.count)") + .font(.system(size: 11)) + .foregroundStyle(Theme.muted) + } + + VStack(alignment: .leading, spacing: 3) { + Text("\(account.balance) XMR") + .font(.system(size: 26, weight: .bold, design: .rounded)) + .foregroundStyle(Theme.text) + if let fiat = model.fiatValue(of: account.balance, chain: .xmr) { + Text(fiat) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(Theme.muted) + } + if account.unlocked != account.balance { + Text("\(account.unlocked) XMR unlocked") + .font(.system(size: 11)) + .foregroundStyle(Theme.muted) + } + } + + HStack(spacing: 8) { + AddressRow(address: account.address) + Button { + qrAddress = AddressQR(title: account.title, address: account.address) + } label: { + Image(systemName: "qrcode") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Theme.accent) + .frame(width: 36, height: 36) + .background(Theme.surfaceAlt) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + } + } + } + .padding(.bottom, 18) + } + private var moneroUnavailableCard: some View { VStack(spacing: 14) { ChainPicker(selection: Binding(get: { model.chain }, diff --git a/apps/ios/Koins/Views/SettingsView.swift b/apps/ios/Koins/Views/SettingsView.swift index 0da3367..b37eda8 100644 --- a/apps/ios/Koins/Views/SettingsView.swift +++ b/apps/ios/Koins/Views/SettingsView.swift @@ -4,12 +4,48 @@ struct SettingsView: View { @EnvironmentObject private var model: AppModel @Environment(\.dismiss) private var dismiss + private let currencies = [ + "USD", "EUR", "GBP", "CHF", "JPY", "CNY", "CAD", "AUD", + "NZD", "SGD", "HKD", "KRW", "INR", "BRL", "SEK", "NOK", + "DKK", "MXN", "ZAR", "TRY", + ] + var body: some View { ZStack { Theme.background.ignoresSafeArea() VStack(alignment: .leading, spacing: 18) { ScreenHeader(title: "Settings", subtitle: "Security and device options", onBack: { dismiss() }) + Card { + VStack(alignment: .leading, spacing: 10) { + Text("Currency") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Theme.muted) + + LazyVGrid(columns: [GridItem(.adaptive(minimum: 64), spacing: 8)], spacing: 8) { + ForEach(currencies, id: \.self) { currency in + let selected = model.fiatCurrency == currency + Button { + model.fiatCurrency = currency + } label: { + Text(currency) + .font(.system(size: 13, weight: .medium)) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(selected ? Theme.accent : Theme.surfaceAlt) + .foregroundStyle(selected ? Color.black : Theme.text) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + } + } + } + + Text("Prices come from CoinGecko (with a Kraken fallback for USD and EUR) and refresh about once a minute.") + .font(.system(size: 11)) + .foregroundStyle(Theme.muted) + .padding(.top, 4) + } + } + Card { Toggle(isOn: Binding(get: { model.faceIDEnabled }, set: { model.faceIDEnabled = $0 })) { @@ -38,7 +74,7 @@ struct SettingsView: View { .padding(.top, 12) } .preferredColorScheme(.dark) - .presentationDetents([.medium]) + .presentationDetents([.medium, .large]) .presentationDragIndicator(.visible) } } \ No newline at end of file