Give each Monero account a swipeable card with its balance and QR

This commit is contained in:
2026-10-01 22:05:19 +08:00
parent e3846fe881
commit 994ba5ed6b
4 changed files with 189 additions and 50 deletions
+27 -13
View File
@@ -15,7 +15,7 @@ final class AppModel: ObservableObject {
@Published var moneroReady = false @Published var moneroReady = false
@Published var moneroAddress: String? @Published var moneroAddress: String?
@Published var moneroAccounts: [MoneroAccount] = [] @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. /// Whether the app is behind the biometric lock screen right now.
@Published var locked = false @Published var locked = false
@@ -57,24 +57,38 @@ final class AppModel: ObservableObject {
// MARK: - Fiat display // 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() let formatter = NumberFormatter()
formatter.numberStyle = .currency formatter.numberStyle = .currency
formatter.currencyCode = "USD" formatter.currencyCode = fiatCurrency
formatter.locale = Locale(identifier: "en_US") formatter.locale = Locale(identifier: "en_US")
return formatter return formatter.string(from: fiat as NSDecimalNumber)
}()
/// 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 { private func refreshPrices() async {
prices = await PriceService.shared.prices() prices = await PriceService.shared.prices(currency: fiatCurrency)
} }
// MARK: - Biometric lock // MARK: - Biometric lock
+47 -20
View File
@@ -1,34 +1,49 @@
import Foundation import Foundation
/// USD prices for the two tickers the wallet supports, fetched from a public /// Fiat 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. /// 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 { final class PriceService {
static let shared = PriceService() static let shared = PriceService()
struct Prices: Codable { struct Prices: Codable {
var xrp: Double? var xrp: Double?
var xmr: Double? var xmr: Double?
var currency: String
var fetchedAt: Date? var fetchedAt: Date?
} }
private static let cacheLifetime: TimeInterval = 60 private static let cacheLifetime: TimeInterval = 60
private static let defaultsKey = "koins.prices"
private var memory: Prices? private var memory: Prices?
private var inFlight: Task<Prices, Never>? private var inFlight: Task<Prices, Never>?
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 { func prices(currency: String) async -> Prices {
if let memory, let fetchedAt = memory.fetchedAt, if let memory,
memory.currency == currency,
let fetchedAt = memory.fetchedAt,
Date().timeIntervalSince(fetchedAt) < Self.cacheLifetime { Date().timeIntervalSince(fetchedAt) < Self.cacheLifetime {
return memory return memory
} }
if let inFlight { return await inFlight.value } if let inFlight { return await inFlight.value }
let task = Task<Prices, Never> { [weak self] in let task = Task<Prices, Never> { [weak self] in
guard let self else { return Prices(fetchedAt: nil) } guard let self else { return Prices(currency: currency, fetchedAt: nil) }
let prices = await self.fetch() let fetched = await self.fetch(currency: currency)
self.memory = prices self.memory = fetched
return prices if let data = try? JSONEncoder().encode(fetched) {
UserDefaults.standard.set(data, forKey: Self.defaultsKey)
}
return fetched
} }
inFlight = task inFlight = task
let result = await task.value let result = await task.value
@@ -36,13 +51,14 @@ final class PriceService {
return result return result
} }
private func fetch() async -> Prices { private func fetch(currency: String) async -> Prices {
if let prices = try? await fromCoinGecko() { return prices } if let prices = try? await fromCoinGecko(currency: currency) { return prices }
return (try? await fromKraken()) ?? Prices(fetchedAt: nil) return (try? await fromKraken(currency: currency)) ?? Prices(currency: currency, fetchedAt: nil)
} }
private func fromCoinGecko() async throws -> Prices { private func fromCoinGecko(currency: String) async throws -> Prices {
guard let url = URL(string: "https://api.coingecko.com/api/v3/simple/price?ids=monero,ripple&vs_currencies=usd") else { 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") throw XRPError.network("Invalid price endpoint")
} }
let (data, response) = try await URLSession.shared.data(from: url) 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] ?? [:] let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:]
return Prices( return Prices(
xrp: (json["ripple"] 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["usd"] as? NSNumber)?.doubleValue }, xmr: (json["monero"] as? [String: Any]).flatMap { ($0[code] as? NSNumber)?.doubleValue },
currency: currency,
fetchedAt: Date()) fetchedAt: Date())
} }
private func fromKraken() async throws -> Prices { /// Kraken only lists USD and EUR for both assets; anything else just yields
guard let url = URL(string: "https://api.kraken.com/0/public/Ticker?pair=XRPUSD,XMRUSD") else { /// 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") throw XRPError.network("Invalid price endpoint")
} }
let (data, response) = try await URLSession.shared.data(from: url) 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 { entry in (entry["c"] as? [Any])?.first }
.flatMap { ($0 as? String).flatMap(Double.init) } .flatMap { ($0 as? String).flatMap(Double.init) }
} }
return Prices(xrp: lastTrade("XXRPZUSD") ?? lastTrade("XRPUSD"), return Prices(xrp: lastTrade(pairs.xrp),
xmr: lastTrade("XXMRZUSD") ?? lastTrade("XMRUSD"), xmr: lastTrade(pairs.xmr),
currency: currency,
fetchedAt: Date()) fetchedAt: Date())
} }
} }
+62
View File
@@ -90,6 +90,8 @@ struct HomeView: View {
@State private var showDeleteConfirm = false @State private var showDeleteConfirm = false
@State private var selectedTransaction: TransactionRecord? @State private var selectedTransaction: TransactionRecord?
@State private var showSettings = false @State private var showSettings = false
@State private var activeAccount = 0
@State private var qrAddress: AddressQR?
var body: some View { var body: some View {
VStack(spacing: 18) { VStack(spacing: 18) {
@@ -115,6 +117,9 @@ struct HomeView: View {
symbol: model.symbol, symbol: model.symbol,
chain: model.chain) { selectedTransaction = nil } chain: model.chain) { selectedTransaction = nil }
} }
.sheet(item: $qrAddress) { item in
AddressQRSheet(title: item.title, address: item.address) { qrAddress = nil }
}
.sheet(isPresented: $showSettings) { .sheet(isPresented: $showSettings) {
SettingsView() SettingsView()
.environmentObject(model) .environmentObject(model)
@@ -176,6 +181,17 @@ struct HomeView: View {
} }
} }
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)
}
}
.tabViewStyle(.page(indexDisplayMode: .always))
.frame(height: 208)
} else {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("Balance") Text("Balance")
.font(.system(size: 11, weight: .medium)) .font(.system(size: 11, weight: .medium))
@@ -196,6 +212,7 @@ struct HomeView: View {
} }
AddressRow(address: model.displayAddress) AddressRow(address: model.displayAddress)
}
if model.chain == .xmr, let status = model.syncStatus { if model.chain == .xmr, let status = model.syncStatus {
SyncProgress(status: status, eta: model.syncEta) 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 { private var moneroUnavailableCard: some View {
VStack(spacing: 14) { VStack(spacing: 14) {
ChainPicker(selection: Binding(get: { model.chain }, ChainPicker(selection: Binding(get: { model.chain },
+37 -1
View File
@@ -4,12 +4,48 @@ struct SettingsView: View {
@EnvironmentObject private var model: AppModel @EnvironmentObject private var model: AppModel
@Environment(\.dismiss) private var dismiss @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 { var body: some View {
ZStack { ZStack {
Theme.background.ignoresSafeArea() Theme.background.ignoresSafeArea()
VStack(alignment: .leading, spacing: 18) { VStack(alignment: .leading, spacing: 18) {
ScreenHeader(title: "Settings", subtitle: "Security and device options", onBack: { dismiss() }) 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 { Card {
Toggle(isOn: Binding(get: { model.faceIDEnabled }, Toggle(isOn: Binding(get: { model.faceIDEnabled },
set: { model.faceIDEnabled = $0 })) { set: { model.faceIDEnabled = $0 })) {
@@ -38,7 +74,7 @@ struct SettingsView: View {
.padding(.top, 12) .padding(.top, 12)
} }
.preferredColorScheme(.dark) .preferredColorScheme(.dark)
.presentationDetents([.medium]) .presentationDetents([.medium, .large])
.presentationDragIndicator(.visible) .presentationDragIndicator(.visible)
} }
} }