329 lines
10 KiB
Swift
329 lines
10 KiB
Swift
import PhotosUI
|
|
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?
|
|
@State private var selectedTransaction: TransactionRecord?
|
|
|
|
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 let fiat = model.fiatBalance {
|
|
Text("\(fiat)")
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundStyle(Theme.muted)
|
|
}
|
|
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
|
|
Button {
|
|
selectedTransaction = record
|
|
} label: {
|
|
TransactionRow(record: record, symbol: model.symbol)
|
|
}
|
|
.buttonStyle(.plain)
|
|
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)
|
|
}
|
|
}
|
|
.bottomDrawer(isPresented: Binding(
|
|
get: { selectedTransaction != nil },
|
|
set: { if !$0 { selectedTransaction = nil } }
|
|
)) {
|
|
if let record = selectedTransaction {
|
|
TransactionDetailView(record: record,
|
|
symbol: model.symbol,
|
|
chain: model.chain) { selectedTransaction = nil }
|
|
}
|
|
}
|
|
.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?
|
|
@State private var status: String?
|
|
@State private var showScanner = false
|
|
@State private var photoItem: PhotosPickerItem?
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 18) {
|
|
ScreenHeader(title: "Send \(model.symbol)",
|
|
subtitle: "Balance \(model.balance.total) \(model.symbol)",
|
|
onBack: { route = .home })
|
|
|
|
HStack(spacing: 10) {
|
|
SecondaryButton(title: "Scan QR") { showScanner = true }
|
|
PhotosPicker(selection: $photoItem, matching: .images, photoLibrary: .shared()) {
|
|
Text("Photo")
|
|
.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))
|
|
}
|
|
}
|
|
|
|
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 = status {
|
|
MessageBanner(message: message, isError: false)
|
|
}
|
|
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)
|
|
}
|
|
.sheet(isPresented: $showScanner) {
|
|
QRScannerView(onScan: { value in
|
|
showScanner = false
|
|
applyScanned(value)
|
|
}, onCancel: {
|
|
showScanner = false
|
|
})
|
|
.ignoresSafeArea()
|
|
}
|
|
.onChange(of: photoItem) { item in
|
|
guard let item else { return }
|
|
Task {
|
|
if let data = try? await item.loadTransferable(type: Data.self) {
|
|
if let value = QRCodeReader.decodeText(in: data) {
|
|
applyScanned(value)
|
|
} else {
|
|
localError = "No QR code found in that image"
|
|
}
|
|
} else {
|
|
localError = "That image could not be read"
|
|
}
|
|
photoItem = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Fills the form from a scanned code, accepting both plain addresses and
|
|
/// wallet payment URIs (`monero:…?tx_amount=`, `ripple:…?amount=&dt=`).
|
|
private func applyScanned(_ value: String) {
|
|
localError = nil
|
|
status = nil
|
|
guard let payload = QRCodeReader.parse(value) else {
|
|
localError = "That QR code is not a payment request"
|
|
return
|
|
}
|
|
|
|
let isForThisChain = model.chain == .xrp
|
|
? AddressFormat.isXRP(payload.address)
|
|
: AddressFormat.isMonero(payload.address)
|
|
guard isForThisChain else {
|
|
localError = model.chain == .xrp
|
|
? "That looks like a Monero address. Switch to Monero to use it."
|
|
: "That looks like an XRP address. Switch to XRP to use it."
|
|
return
|
|
}
|
|
|
|
destination = payload.address
|
|
if let scannedAmount = payload.amount, !scannedAmount.isEmpty {
|
|
amount = scannedAmount
|
|
}
|
|
if model.chain == .xrp, let scannedTag = payload.tag {
|
|
tag = String(scannedTag)
|
|
}
|
|
|
|
var filled: [String] = ["address"]
|
|
if payload.amount?.isEmpty == false { filled.append("amount") }
|
|
if payload.tag != nil { filled.append("tag") }
|
|
if payload.paymentId?.isEmpty == false { filled.append("payment ID") }
|
|
status = "Filled \(filled.joined(separator: ", ")) from the QR code"
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
} |