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
+260
View File
@@ -0,0 +1,260 @@
import CoreImage.CIFilterBuiltins
import SwiftUI
enum Theme {
static let background = Color(red: 0.035, green: 0.035, blue: 0.05)
static let surface = Color(red: 0.09, green: 0.09, blue: 0.12)
static let surfaceAlt = Color(red: 0.13, green: 0.13, blue: 0.17)
static let border = Color.white.opacity(0.08)
static let accent = Color(red: 1.0, green: 0.56, blue: 0.16)
static let accentSoft = Color(red: 1.0, green: 0.56, blue: 0.16).opacity(0.15)
static let text = Color.white
static let muted = Color.white.opacity(0.55)
static let danger = Color(red: 1.0, green: 0.36, blue: 0.36)
static let success = Color(red: 0.35, green: 0.85, blue: 0.55)
}
struct PrimaryButton: View {
var title: String
var disabled: Bool = false
var action: () -> Void
var body: some View {
Button(action: action) {
Text(title)
.font(.system(size: 16, weight: .semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 15)
.background(disabled ? Theme.surfaceAlt : Theme.accent)
.foregroundStyle(disabled ? Theme.muted : Color.black)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
.disabled(disabled)
}
}
struct SecondaryButton: View {
var title: String
var action: () -> Void
var body: some View {
Button(action: action) {
Text(title)
.font(.system(size: 15, weight: .medium))
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(Theme.surface)
.foregroundStyle(Theme.text)
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(Theme.border))
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
}
}
struct Card<Content: View>: View {
@ViewBuilder var content: Content
var body: some View {
content
.padding(18)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Theme.surface)
.overlay(RoundedRectangle(cornerRadius: 20, style: .continuous).stroke(Theme.border))
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
}
}
struct FieldLabel: View {
var text: String
var body: some View {
Text(text)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Theme.muted)
.padding(.leading, 4)
}
}
struct KoinsTextField: View {
var placeholder: String
@Binding var text: String
var multiline = false
var keyboard: UIKeyboardType = .default
var autocapitalization: TextInputAutocapitalization = .never
var body: some View {
Group {
if multiline {
TextEditor(text: $text)
.scrollContentBackground(.hidden)
.frame(minHeight: 96)
.padding(8)
} else {
TextField(placeholder, text: $text)
.keyboardType(keyboard)
.textInputAutocapitalization(autocapitalization)
.autocorrectionDisabled()
.padding(.horizontal, 14)
.padding(.vertical, 13)
}
}
.font(.system(size: 15))
.foregroundStyle(Theme.text)
.background(Theme.surface)
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(Theme.border))
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
}
struct MessageBanner: View {
var message: String
var isError: Bool
var body: some View {
Text(message)
.font(.system(size: 13))
.foregroundStyle(isError ? Theme.danger : Theme.success)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background((isError ? Theme.danger : Theme.success).opacity(0.12))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
}
struct ChainPicker: View {
@Binding var selection: Chain
var body: some View {
HStack(spacing: 6) {
ForEach(Chain.allCases, id: \.self) { chain in
Button {
selection = chain
} label: {
Text(chain.title)
.font(.system(size: 13, weight: .semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(selection == chain ? Theme.accent : Color.clear)
.foregroundStyle(selection == chain ? Color.black : Theme.muted)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
}
}
}
.padding(4)
.background(Theme.surface)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
}
struct QRCodeView: View {
var text: String
var body: some View {
if let image = QRCodeView.makeImage(from: text) {
Image(uiImage: image)
.interpolation(.none)
.resizable()
.scaledToFit()
.padding(12)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
} else {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Theme.surfaceAlt)
.aspectRatio(1, contentMode: .fit)
}
}
private static func makeImage(from text: String) -> UIImage? {
guard !text.isEmpty else { return nil }
let filter = CIFilter.qrCodeGenerator()
filter.message = Data(text.utf8)
filter.correctionLevel = "M"
guard let output = filter.outputImage else { return nil }
let scaled = output.transformed(by: CGAffineTransform(scaleX: 10, y: 10))
let context = CIContext()
guard let cgImage = context.createCGImage(scaled, from: scaled.extent) else { return nil }
return UIImage(cgImage: cgImage)
}
}
struct TransactionRow: View {
var record: TransactionRecord
var symbol: String
private var formattedDate: String {
guard let timestamp = record.timestamp else { return "Pending" }
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .short
return formatter.string(from: timestamp)
}
var body: some View {
HStack(spacing: 12) {
ZStack {
Circle()
.fill(record.direction == .incoming ? Theme.success.opacity(0.15) : Theme.accentSoft)
.frame(width: 38, height: 38)
Image(systemName: record.direction == .incoming ? "arrow.down.left" : "arrow.up.right")
.font(.system(size: 15, weight: .bold))
.foregroundStyle(record.direction == .incoming ? Theme.success : Theme.accent)
}
VStack(alignment: .leading, spacing: 3) {
Text(record.direction == .incoming ? "Received" : "Sent")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Theme.text)
Text(record.confirmed ? formattedDate : "Pending confirmation")
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
Spacer()
VStack(alignment: .trailing, spacing: 3) {
Text("\(record.direction == .incoming ? "+" : "-")\(record.amount) \(symbol)")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(record.direction == .incoming ? Theme.success : Theme.text)
if let fee = record.fee, !fee.isEmpty, record.direction == .outgoing {
Text("fee \(fee)")
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
}
}
.padding(.vertical, 6)
}
}
struct ScreenHeader: View {
var title: String
var subtitle: String?
var onBack: (() -> Void)?
var body: some View {
HStack(alignment: .center, spacing: 12) {
if let onBack {
Button(action: onBack) {
Image(systemName: "chevron.left")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(Theme.text)
.frame(width: 34, height: 34)
.background(Theme.surface)
.clipShape(Circle())
}
}
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.system(size: 22, weight: .bold))
.foregroundStyle(Theme.text)
if let subtitle {
Text(subtitle)
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
}
}
Spacer()
}
}
}
+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)
}
}
}
+379
View File
@@ -0,0 +1,379 @@
import SwiftUI
enum Route: Equatable {
case home
case create
case importWallet
case dashboard
case send
case receive
}
struct SeedBox: Identifiable {
let id = UUID()
let seed: String
let symbol: String
}
struct RootView: View {
@EnvironmentObject private var model: AppModel
@State private var route: Route = .home
@State private var seedBox: SeedBox?
var body: some View {
ZStack {
Theme.background.ignoresSafeArea()
ScrollView {
VStack(spacing: 18) {
switch route {
case .home:
HomeView(route: $route)
case .create:
CreateWalletView(route: $route, seedBox: $seedBox)
case .importWallet:
ImportWalletView(route: $route)
case .dashboard:
DashboardView(route: $route)
case .send:
SendView(route: $route)
case .receive:
ReceiveView(route: $route)
}
}
.padding(.horizontal, 20)
.padding(.top, 12)
.padding(.bottom, 40)
}
.scrollDismissesKeyboard(.interactively)
}
.preferredColorScheme(.dark)
.task { await model.bootstrap() }
.sheet(item: $seedBox) { box in
SeedRevealView(seed: box.seed, symbol: box.symbol) { seedBox = nil }
}
.onChange(of: model.chain) { _ in
if route == .dashboard { route = .home }
}
}
}
struct HomeView: View {
@EnvironmentObject private var model: AppModel
@Binding var route: Route
@State private var showDeleteConfirm = false
var body: some View {
VStack(spacing: 18) {
header
if model.hasWallet, model.chain == .xrp || model.moneroReady {
walletCard
} else if model.hasWallet {
moneroUnavailableCard
} else {
emptyState
}
if let message = model.errorMessage {
MessageBanner(message: message, isError: true)
}
if let message = model.statusMessage {
MessageBanner(message: message, isError: false)
}
}
.confirmationDialog("Delete this wallet?",
isPresented: $showDeleteConfirm,
titleVisibility: .visible) {
Button("Delete wallet", role: .destructive) {
Task {
await model.deleteWallet()
route = .home
}
}
Button("Cancel", role: .cancel) {}
} message: {
Text("The keys stored on this device are removed. Make sure your recovery seed is backed up.")
}
}
private var header: some View {
VStack(alignment: .leading, spacing: 4) {
Text("Koins")
.font(.system(size: 30, weight: .heavy))
.foregroundStyle(Theme.text)
Text("Monero and XRP wallet")
.font(.system(size: 13))
.foregroundStyle(Theme.muted)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var walletCard: some View {
VStack(spacing: 14) {
ChainPicker(selection: Binding(get: { model.chain },
set: { newValue in Task { await model.select(newValue) } }))
Card {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text(model.wallet?.name ?? "Wallet")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(Theme.text)
Spacer()
if model.chain == .xmr, let status = model.syncStatus {
SyncBadge(status: status)
}
}
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 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)
if model.chain == .xmr, let status = model.syncStatus {
SyncProgress(status: status, eta: model.syncEta)
}
}
}
HStack(spacing: 10) {
PrimaryButton(title: "Send") { route = .send }
SecondaryButton(title: "Receive") { route = .receive }
}
if model.chain == .xmr {
SecondaryButton(title: model.syncStatus?.syncing == true ? "Syncing…" : "Sync now") {
Task { await model.startMoneroSync() }
}
}
HStack(spacing: 10) {
SecondaryButton(title: "Refresh") { Task { await model.refresh(force: true) } }
SecondaryButton(title: "Delete") { showDeleteConfirm = true }
}
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)
}
}
}
}
} else if model.chain == .xrp, model.balance.funded == false {
Text("This account is not funded yet. Send XRP to the address above to activate it.")
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
}
if model.chain == .xrp, model.moneroReady, let status = model.syncStatus, status.syncing {
Card {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Circle()
.fill(status.connected ? Theme.success : Theme.danger)
.frame(width: 7, height: 7)
Text("Monero is syncing in the background")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Theme.text)
Spacer()
Text("\(Int(status.progress * 100))%")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Theme.muted)
}
SyncProgress(status: status, eta: model.syncEta)
}
}
}
}
}
private var moneroUnavailableCard: some View {
VStack(spacing: 14) {
ChainPicker(selection: Binding(get: { model.chain },
set: { newValue in Task { await model.select(newValue) } }))
Card {
VStack(alignment: .leading, spacing: 10) {
Text("Monero wallet")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(Theme.text)
Text("The wallet is on this device but could not be opened. It is safe to retry; nothing was deleted.")
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
SecondaryButton(title: "Retry") {
Task { await model.select(.xmr) }
}
}
}
}
}
private var emptyState: some View {
VStack(spacing: 14) {
ChainPicker(selection: Binding(get: { model.chain },
set: { newValue in Task { await model.select(newValue) } }))
Card {
VStack(alignment: .leading, spacing: 8) {
Text("\(model.chain.title) wallet")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(Theme.text)
Text("Create a new wallet on this device, or import one with its recovery seed.")
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
}
}
VStack(spacing: 10) {
PrimaryButton(title: "Create wallet") { route = .create }
SecondaryButton(title: "Import wallet") { route = .importWallet }
}
}
}
}
struct SyncBadge: View {
var status: SyncStatus
private var label: String {
if status.syncing {
let percent = status.progress * 100
return percent < 10 ? String(format: "Syncing %.1f%%", percent) : "Syncing \(Int(percent))%"
}
return status.connected ? "Synced" : "Offline"
}
var body: some View {
HStack(spacing: 6) {
Circle()
.fill(status.connected ? Theme.success : Theme.danger)
.frame(width: 7, height: 7)
Text(label)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(Theme.muted)
}
}
}
/// Live scan progress: how far the wallet has reached, and roughly how long the
/// remaining blocks will take at the observed rate.
struct SyncProgress: View {
var status: SyncStatus
var eta: String?
var body: some View {
VStack(alignment: .leading, spacing: 6) {
ProgressView(value: status.progress)
.tint(Theme.accent)
HStack(spacing: 8) {
Text("Block \(status.height.formatted()) of \(status.daemonHeight.formatted())")
Spacer()
if status.syncing {
Text(eta.map { "about \($0) left" } ?? "estimating…")
} else if status.connected {
Text("up to date")
}
}
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
}
}
struct AddressRow: View {
var address: String
@State private var copied = false
var body: some View {
HStack(spacing: 8) {
Text(address.isEmpty ? "—" : address)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Theme.muted)
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 6)
Button {
UIPasteboard.general.string = address
copied = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { copied = false }
} label: {
Image(systemName: copied ? "checkmark" : "doc.on.doc")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Theme.accent)
}
.disabled(address.isEmpty)
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(Theme.surfaceAlt)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
}
struct SeedRevealView: View {
var seed: String
var symbol: String
var onDone: () -> Void
@State private var acknowledged = false
var body: some View {
ZStack {
Theme.background.ignoresSafeArea()
VStack(alignment: .leading, spacing: 16) {
Text("Your recovery phrase")
.font(.system(size: 24, weight: .bold))
.foregroundStyle(Theme.text)
Text("Write these words down in order and keep them offline. Anyone with this phrase can spend your \(symbol).")
.font(.system(size: 13))
.foregroundStyle(Theme.muted)
Text(seed)
.font(.system(size: 15, design: .monospaced))
.foregroundStyle(Theme.text)
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Theme.surface)
.overlay(RoundedRectangle(cornerRadius: 16, style: .continuous).stroke(Theme.border))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
Button {
UIPasteboard.general.string = seed
} label: {
Label("Copy phrase", systemImage: "doc.on.doc")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(Theme.accent)
}
Toggle(isOn: $acknowledged) {
Text("I have written down my phrase")
.font(.system(size: 13))
.foregroundStyle(Theme.muted)
}
.tint(Theme.accent)
Spacer()
PrimaryButton(title: "Continue", disabled: !acknowledged, action: onDone)
}
.padding(22)
}
}
}
+142
View File
@@ -0,0 +1,142 @@
import SwiftUI
struct CreateWalletView: View {
@EnvironmentObject private var model: AppModel
@Binding var route: Route
@Binding var seedBox: SeedBox?
@State private var name = ""
@State private var localError: String?
var body: some View {
VStack(alignment: .leading, spacing: 18) {
ScreenHeader(title: "Create wallet",
subtitle: "A new \(model.chain.title) wallet on this device",
onBack: { route = .home })
VStack(alignment: .leading, spacing: 6) {
FieldLabel(text: "Wallet name")
KoinsTextField(placeholder: "My \(model.symbol) wallet", text: $name)
}
if model.chain == .xmr {
Card {
Text("The wallet file is created locally and encrypted on this device. Syncing starts right away and can take a while for a new Monero wallet.")
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
}
}
if let message = localError ?? model.errorMessage {
MessageBanner(message: message, isError: true)
}
PrimaryButton(title: model.isBusy ? "Generating…" : "Generate wallet",
disabled: model.isBusy) {
Task {
localError = nil
model.clearMessages()
do {
let seed = try await model.createWallet(name: name)
seedBox = SeedBox(seed: seed, symbol: model.symbol)
route = .dashboard
} catch {
localError = error.localizedDescription
}
}
}
Spacer(minLength: 0)
}
}
}
struct ImportWalletView: View {
@EnvironmentObject private var model: AppModel
@Binding var route: Route
@State private var name = ""
@State private var secret = ""
@State private var restoreHeight = ""
@State private var localError: String?
@State private var tipError: String?
@State private var isLoadingTip = false
var body: some View {
VStack(alignment: .leading, spacing: 18) {
ScreenHeader(title: "Import wallet",
subtitle: "Restore a \(model.chain.title) wallet from its seed",
onBack: { route = .home })
VStack(alignment: .leading, spacing: 6) {
FieldLabel(text: "Wallet name")
KoinsTextField(placeholder: "My \(model.symbol) wallet", text: $name)
}
VStack(alignment: .leading, spacing: 6) {
FieldLabel(text: model.chain == .xrp ? "Recovery phrase" : "Mnemonic seed")
KoinsTextField(placeholder: model.chain == .xrp ? "12 words, or a family seed (sEd…)" : "25 word seed phrase",
text: $secret,
multiline: model.chain == .xmr)
}
if model.chain == .xmr {
VStack(alignment: .leading, spacing: 6) {
FieldLabel(text: "Restore height (optional)")
KoinsTextField(placeholder: "e.g. 3200000", text: $restoreHeight, keyboard: .numberPad)
}
Card {
VStack(alignment: .leading, spacing: 10) {
Text("Without a height the wallet scans the whole chain — millions of blocks, which takes hours on a phone. Set the block your wallet was first used, or start from the current tip if you only care about new funds.")
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
Button {
Task {
tipError = nil
isLoadingTip = true
defer { isLoadingTip = false }
do {
let tip = try await MoneroService.shared.daemonHeight()
restoreHeight = String(tip)
} catch {
tipError = error.localizedDescription
}
}
} label: {
Text(isLoadingTip ? "Fetching chain tip…" : "Use the current chain tip")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(Theme.accent)
}
.disabled(isLoadingTip)
if let tipError {
Text(tipError)
.font(.system(size: 11))
.foregroundStyle(Theme.danger)
}
}
}
}
if let message = localError ?? model.errorMessage {
MessageBanner(message: message, isError: true)
}
PrimaryButton(title: model.isBusy ? "Importing…" : "Import wallet",
disabled: model.isBusy) {
Task {
localError = nil
model.clearMessages()
do {
let height = UInt64(restoreHeight.trimmingCharacters(in: .whitespaces))
try await model.importWallet(name: name, secret: secret, restoreHeight: height)
route = .dashboard
} catch {
localError = error.localizedDescription
}
}
}
Spacer(minLength: 0)
}
}
}