Replace the JavaScript mobile app with a native Swift iOS wallet
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user