Give each Monero account a swipeable card with its balance and QR
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user