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) } } .sheet(item: $selectedTransaction) { record in 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) } } }