Replace the JavaScript mobile app with a native Swift iOS wallet

This commit is contained in:
2026-09-19 13:33:11 +08:00
parent d80b58b704
commit 3e1a39d4d7
79 changed files with 25482 additions and 4023 deletions
+228
View File
@@ -0,0 +1,228 @@
import SwiftUI
struct DashboardView: View {
@EnvironmentObject private var model: AppModel
@Binding var route: Route
@State private var showSeed = false
@State private var revealedSeed: String?
@State private var seedError: String?
var body: some View {
VStack(alignment: .leading, spacing: 18) {
ScreenHeader(title: model.wallet?.name ?? "Wallet",
subtitle: model.chain.title,
onBack: { route = .home })
Card {
VStack(alignment: .leading, spacing: 10) {
Text("Balance")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(Theme.muted)
Text("\(model.balance.total) \(model.symbol)")
.font(.system(size: 32, weight: .bold, design: .rounded))
.foregroundStyle(Theme.text)
if model.chain == .xmr, model.balance.unlocked != model.balance.total {
Text("\(model.balance.unlocked) \(model.symbol) spendable now")
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
AddressRow(address: model.displayAddress)
}
}
if model.chain == .xmr {
Card {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("Sync")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.text)
Spacer()
if let status = model.syncStatus { SyncBadge(status: status) }
}
if let status = model.syncStatus {
SyncProgress(status: status, eta: model.syncEta)
} else {
Text("Sync status is not available yet.")
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
}
}
}
HStack(spacing: 10) {
PrimaryButton(title: "Send") { route = .send }
SecondaryButton(title: "Receive") { route = .receive }
}
if !model.transactions.isEmpty {
Card {
VStack(alignment: .leading, spacing: 10) {
Text("Activity")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.muted)
ForEach(model.transactions) { record in
TransactionRow(record: record, symbol: model.symbol)
if record.id != model.transactions.last?.id {
Divider().overlay(Theme.border)
}
}
}
}
}
if let message = model.errorMessage {
MessageBanner(message: message, isError: true)
}
if let message = model.statusMessage {
MessageBanner(message: message, isError: false)
}
SecondaryButton(title: "Reveal recovery phrase") {
Task {
seedError = nil
do {
revealedSeed = try await model.revealSeed()
showSeed = true
} catch {
seedError = error.localizedDescription
}
}
}
if let seedError {
MessageBanner(message: seedError, isError: true)
}
}
.sheet(isPresented: $showSeed) {
if let revealedSeed {
SeedRevealView(seed: revealedSeed, symbol: model.symbol) { showSeed = false }
}
}
}
}
struct SendView: View {
@EnvironmentObject private var model: AppModel
@Binding var route: Route
@State private var destination = ""
@State private var amount = ""
@State private var tag = ""
@State private var priority: TransactionPriority = .defaultPriority
@State private var localError: String?
var body: some View {
VStack(alignment: .leading, spacing: 18) {
ScreenHeader(title: "Send \(model.symbol)",
subtitle: "Balance \(model.balance.total) \(model.symbol)",
onBack: { route = .home })
VStack(alignment: .leading, spacing: 6) {
FieldLabel(text: model.chain == .xrp ? "Destination address" : "Recipient address")
KoinsTextField(placeholder: model.chain == .xrp ? "r…" : "4…", text: $destination)
}
VStack(alignment: .leading, spacing: 6) {
FieldLabel(text: "Amount (\(model.symbol))")
KoinsTextField(placeholder: "0.0", text: $amount, keyboard: .decimalPad)
}
if model.chain == .xrp {
VStack(alignment: .leading, spacing: 6) {
FieldLabel(text: "Destination tag (optional)")
KoinsTextField(placeholder: "e.g. 12345", text: $tag, keyboard: .numberPad)
}
}
if model.chain == .xmr {
VStack(alignment: .leading, spacing: 8) {
FieldLabel(text: "Priority")
Picker("Priority", selection: $priority) {
ForEach([TransactionPriority.low, .medium, .high, .defaultPriority], id: \.rawValue) { item in
Text(item.label).tag(item)
}
}
.pickerStyle(.segmented)
}
}
if let message = localError ?? model.errorMessage {
MessageBanner(message: message, isError: true)
}
if let message = model.statusMessage {
MessageBanner(message: message, isError: false)
}
PrimaryButton(title: model.isBusy ? "Sending…" : "Send", disabled: model.isBusy) {
Task {
localError = nil
model.clearMessages()
do {
try await model.send(to: destination, amount: amount, tag: tag, priority: priority)
amount = ""
tag = ""
route = .dashboard
} catch {
localError = error.localizedDescription
}
}
}
Spacer(minLength: 0)
}
}
}
struct ReceiveView: View {
@EnvironmentObject private var model: AppModel
@Binding var route: Route
private var address: String {
model.displayAddress
}
var body: some View {
VStack(alignment: .leading, spacing: 18) {
ScreenHeader(title: "Receive \(model.symbol)",
subtitle: "Share this address to get paid",
onBack: { route = .home })
Card {
VStack(spacing: 16) {
QRCodeView(text: address)
.frame(maxWidth: 240)
Text(address)
.font(.system(size: 12, design: .monospaced))
.foregroundStyle(Theme.muted)
.multilineTextAlignment(.center)
.lineLimit(3)
.truncationMode(.middle)
}
.frame(maxWidth: .infinity)
}
SecondaryButton(title: "Copy address") {
UIPasteboard.general.string = address
}
if model.chain == .xrp {
Card {
Text("XRP accounts must hold at least 1 XRP as a reserve to be active. Only send XRP on the XRP Ledger to this address.")
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
}
} else {
Card {
Text("Send only Monero (XMR) to this address. Transfers need confirmations before the balance becomes spendable.")
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
}
}
Spacer(minLength: 0)
}
}
}