Add Monero subaccounts and subaddresses with a QR per address

This commit is contained in:
2026-09-20 00:11:53 +08:00
parent 1a23c730c6
commit caca4321cc
7 changed files with 332 additions and 3 deletions
+165
View File
@@ -0,0 +1,165 @@
import SwiftUI
struct AccountsView: View {
@EnvironmentObject private var model: AppModel
@Binding var route: Route
@State private var error: String?
@State private var busy = false
@State private var expandedAddress: String?
@State private var showAccountPrompt = false
@State private var showSubaddressPrompt = false
@State private var pendingLabel = ""
@State private var subaddressAccount = 0
var body: some View {
VStack(alignment: .leading, spacing: 18) {
ScreenHeader(title: "Accounts & addresses",
subtitle: "Each address gets its own QR code",
onBack: { route = .home })
PrimaryButton(title: busy ? "Working…" : "New account", disabled: busy) {
showAccountPrompt = true
}
if let error {
MessageBanner(message: error, isError: true)
}
ForEach(model.moneroAccounts) { account in
accountCard(account)
}
if model.moneroAccounts.isEmpty {
Text("No accounts yet. Accounts and addresses appear after the wallet opens.")
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
}
}
.task(id: model.moneroReady) { await model.loadAccounts() }
.alert("Account name", isPresented: $showAccountPrompt) {
TextField("e.g. Savings", text: $pendingLabel)
Button("Create") { createAccount() }
Button("Cancel", role: .cancel) { pendingLabel = "" }
}
.alert("Address label", isPresented: $showSubaddressPrompt) {
TextField("e.g. From Alice", text: $pendingLabel)
Button("Create") { createSubaddress() }
Button("Cancel", role: .cancel) { pendingLabel = "" }
}
}
private func accountCard(_ account: MoneroAccount) -> some View {
Card {
VStack(alignment: .leading, spacing: 10) {
HStack {
VStack(alignment: .leading, spacing: 3) {
Text(account.title)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Theme.text)
Text("\(account.balance) XMR")
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
}
Spacer()
}
addressRow(id: "\(account.index)/primary",
title: "Primary address",
address: account.address,
isUsed: true)
if !account.subaddresses.isEmpty {
Divider().overlay(Theme.border)
ForEach(account.subaddresses.filter { $0.index != 0 }) { subaddress in
addressRow(id: subaddress.id,
title: subaddress.label.isEmpty ? "Address \(subaddress.index)" : subaddress.label,
address: subaddress.address,
isUsed: subaddress.isUsed,
balance: subaddress.balance)
}
}
SecondaryButton(title: "New address in this account") {
subaddressAccount = account.index
pendingLabel = ""
showSubaddressPrompt = true
}
}
}
}
private func addressRow(id: String, title: String, address: String, isUsed: Bool, balance: String? = nil) -> some View {
VStack(spacing: 10) {
HStack(spacing: 8) {
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Theme.muted)
Text(address.isEmpty ? "—" : address)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Theme.text)
.lineLimit(1)
.truncationMode(.middle)
if let balance {
Text("\(balance) XMR\(isUsed ? "" : " · unused")")
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
}
Spacer(minLength: 6)
Button {
expandedAddress = expandedAddress == id ? nil : id
} label: {
Image(systemName: expandedAddress == id ? "xmark" : "qrcode")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Theme.accent)
.frame(width: 32, height: 32)
.background(Theme.surfaceAlt)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
}
}
if expandedAddress == id {
VStack(spacing: 10) {
QRCodeView(text: address)
.frame(maxWidth: 180)
HStack(spacing: 8) {
SecondaryButton(title: "Copy") {
UIPasteboard.general.string = address
}
}
}
.frame(maxWidth: .infinity)
}
}
}
private func createAccount() {
busy = true
error = nil
Task {
defer { busy = false }
do {
try await model.addAccount(label: pendingLabel)
pendingLabel = ""
} catch {
self.error = error.localizedDescription
}
}
}
private func createSubaddress() {
busy = true
error = nil
Task {
defer { busy = false }
do {
try await model.addSubaddress(accountIndex: subaddressAccount, label: pendingLabel)
pendingLabel = ""
} catch {
self.error = error.localizedDescription
}
}
}
}
+10 -3
View File
@@ -7,6 +7,7 @@ enum Route: Equatable {
case dashboard
case send
case receive
case accounts
}
struct SeedBox: Identifiable {
@@ -18,7 +19,8 @@ struct SeedBox: Identifiable {
struct RootView: View {
@EnvironmentObject private var model: AppModel
@Environment(\.scenePhase) private var scenePhase
@State private var route: Route = .home
@State private var route: Route = ProcessInfo.processInfo.arguments.contains("-koinsScreenAccounts")
? .accounts : .home
@State private var seedBox: SeedBox?
var body: some View {
@@ -39,6 +41,8 @@ struct RootView: View {
SendView(route: $route)
case .receive:
ReceiveView(route: $route)
case .accounts:
AccountsView(route: $route)
}
}
.padding(.horizontal, 20)
@@ -159,8 +163,11 @@ struct HomeView: View {
}
if model.chain == .xmr {
SecondaryButton(title: model.syncStatus?.syncing == true ? "Syncing…" : "Sync now") {
Task { await model.startMoneroSync() }
HStack(spacing: 10) {
SecondaryButton(title: model.syncStatus?.syncing == true ? "Syncing…" : "Sync now") {
Task { await model.startMoneroSync() }
}
SecondaryButton(title: "Addresses") { route = .accounts }
}
}