import SwiftUI /// Swap between the two wallets via kyc.rip, the public no-KYC aggregator. struct SwapView: View { @EnvironmentObject private var model: AppModel @Binding var route: Route @State private var from: Chain = .xrp @State private var amount = "" @State private var estimate: SwapEstimate? @State private var estimating = false @State private var creating = false @State private var sendingDeposit = false @State private var createdOrder: SwapOrder? @State private var localError: String? @State private var qrAddress: AddressQR? private var to: Chain { from == .xrp ? .xmr : .xrp } private var fromBalance: Balance { if from == model.chain { return model.balance } return WalletStore.cachedState(from)?.balance ?? Balance(total: "0", unlocked: "", funded: false) } private var fromBalanceText: String { let value = Decimal(string: fromBalance.total) ?? 0 let formatter = NumberFormatter() formatter.maximumFractionDigits = 6 return formatter.string(from: value as NSDecimalNumber) ?? "0" } var body: some View { VStack(alignment: .leading, spacing: 18) { ScreenHeader(title: "Swap", subtitle: "XMR ↔ XRP, via kyc.rip — no account, no KYC", onBack: { route = .home }) if let order = createdOrder { orderCard(order) } else { swapForm } if !model.swapOrders.isEmpty { recentSwaps } } .task { from = model.chain model.loadSwapOrders() } .bottomDrawer(isPresented: Binding( get: { qrAddress != nil }, set: { if !$0 { qrAddress = nil } } )) { if let item = qrAddress { AddressQRSheet(title: item.title, address: item.address) { qrAddress = nil } } } } // MARK: - The form private var swapForm: some View { VStack(alignment: .leading, spacing: 14) { Card { VStack(alignment: .leading, spacing: 10) { HStack { Text("From") .font(.system(size: 12, weight: .medium)) .foregroundStyle(Theme.muted) Spacer() Text("Balance: \(fromBalanceText) \(from.symbol)") .font(.system(size: 11)) .foregroundStyle(Theme.muted) } HStack(spacing: 10) { KoinsTextField(placeholder: "Amount", text: $amount, keyboard: .decimalPad) Button("Max") { amount = fromBalanceText } .font(.system(size: 12, weight: .semibold)) .foregroundStyle(Theme.accent) } HStack(spacing: 10) { Text(from.title) .font(.system(size: 14, weight: .semibold)) .foregroundStyle(Theme.text) Spacer() Button { from = to amount = "" estimate = nil } label: { Image(systemName: "arrow.up.arrow.down") .font(.system(size: 14, weight: .semibold)) .foregroundStyle(Theme.accent) .frame(width: 34, height: 34) .background(Theme.surfaceAlt) .clipShape(Circle()) } .accessibilityLabel("Swap direction") } Divider().overlay(Theme.border) HStack { Text("To") .font(.system(size: 12, weight: .medium)) .foregroundStyle(Theme.muted) Spacer() Text(to.title) .font(.system(size: 14, weight: .semibold)) .foregroundStyle(Theme.text) } if let estimate { VStack(alignment: .leading, spacing: 4) { Text("≈ \(String(format: "%.6f", estimate.amountTo)) \(to.symbol)") .font(.system(size: 22, weight: .bold, design: .rounded)) .foregroundStyle(Theme.text) if let route = estimate.routes.first { Text("via \(route.provider)\(route.eta.map { " · ~\($0) min" } ?? "")") .font(.system(size: 11)) .foregroundStyle(Theme.muted) } } } else { Text("Enter an amount and get a quote") .font(.system(size: 12)) .foregroundStyle(Theme.muted) } } } if let localError { MessageBanner(message: localError, isError: true) } if let status = model.statusMessage { MessageBanner(message: status, isError: false) } if estimate == nil { PrimaryButton(title: estimating ? "Getting quote…" : "Get quote", disabled: estimating) { Task { await loadQuote() } } } else { PrimaryButton(title: creating ? "Creating…" : "Swap", disabled: creating) { Task { await create() } } SecondaryButton(title: "Change amount") { estimate = nil } } Spacer(minLength: 0) } } // MARK: - The order private func orderCard(_ order: SwapOrder) -> some View { VStack(alignment: .leading, spacing: 14) { Card { VStack(alignment: .leading, spacing: 10) { HStack { Text("Swap deposit") .font(.system(size: 15, weight: .semibold)) .foregroundStyle(Theme.text) Spacer() SwapStatusBadge(status: order.status ?? "WAITING") } Text("Send exactly \(order.depositAmount, specifier: "%.6g") \(order.fromTicker) to this address:") .font(.system(size: 12)) .foregroundStyle(Theme.muted) HStack(spacing: 8) { Text(order.depositAddress) .font(.system(size: 11, design: .monospaced)) .foregroundStyle(Theme.text) .lineLimit(1) .truncationMode(.middle) Button { qrAddress = AddressQR(title: "Deposit \(order.fromTicker)", address: order.depositAddress) } label: { Image(systemName: "qrcode") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(Theme.accent) .frame(width: 34, height: 34) .background(Theme.surfaceAlt) .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) } .disabled(order.depositAddress.isEmpty) } .padding(.horizontal, 12) .padding(.vertical, 10) .background(Theme.surfaceAlt) .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) if let memo = order.depositMemo, !memo.isEmpty { HStack { Text("Tag") .font(.system(size: 12, weight: .medium)) .foregroundStyle(Theme.muted) Spacer() Text(memo) .font(.system(size: 13, design: .monospaced)) .foregroundStyle(Theme.text) } } HStack { Text("You get") .font(.system(size: 12, weight: .medium)) .foregroundStyle(Theme.muted) Spacer() Text("≈ \(order.toAmount, specifier: "%.6g") \(order.toTicker)") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(Theme.success) } if let eta = order.eta { HStack { Text("Usually takes") .font(.system(size: 12, weight: .medium)) .foregroundStyle(Theme.muted) Spacer() Text("~\(eta) min") .font(.system(size: 13)) .foregroundStyle(Theme.text) } } } } PrimaryButton(title: sendingDeposit ? "Sending…" : "Send deposit", disabled: sendingDeposit) { Task { await sendDeposit(order) } } SecondaryButton(title: "Refresh status") { Task { _ = await model.refreshSwapStatus(order) } } if let localError { MessageBanner(message: localError, isError: true) } Spacer(minLength: 0) } } private var recentSwaps: some View { VStack(alignment: .leading, spacing: 10) { Text("Recent swaps") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(Theme.muted) ForEach(model.swapOrders.prefix(6)) { order in Button { createdOrder = order } label: { HStack(spacing: 10) { VStack(alignment: .leading, spacing: 2) { Text("\(order.fromAmount, specifier: "%.6g") \(order.fromTicker) → \(order.toAmount, specifier: "%.6g") \(order.toTicker)") .font(.system(size: 13, weight: .medium)) .foregroundStyle(Theme.text) Text(order.createdAt, style: .date) .font(.system(size: 11)) .foregroundStyle(Theme.muted) } Spacer() SwapStatusBadge(status: order.status ?? "WAITING") } .padding(12) .background(Theme.surface) .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) } .buttonStyle(.plain) .contentShape(Rectangle()) } } } // MARK: - Actions private func loadQuote() async { localError = nil estimating = true defer { estimating = false } do { estimate = try await model.estimateSwap(amount: amount, from: from, to: to) if estimate?.routes.isEmpty == true { localError = "No provider offers this swap right now" estimate = nil } } catch { localError = error.localizedDescription } } private func create() async { guard let route = estimate?.routes.first else { return } localError = nil creating = true defer { creating = false } do { createdOrder = try await model.createSwap(route: route, amountFrom: amount, from: from, to: to) } catch { localError = error.localizedDescription } } private func sendDeposit(_ order: SwapOrder) async { localError = nil sendingDeposit = true defer { sendingDeposit = false } do { try await model.sendSwapDeposit(order) await model.refresh(force: true) } catch { localError = error.localizedDescription } } } struct SwapStatusBadge: View { var status: String private var colour: Color { switch status { case "FINISHED": return Theme.success case "WAITING", "CONFIRMING", "EXCHANGING", "SENDING": return Theme.accent default: return Theme.danger } } var body: some View { Text(status.lowercased().capitalized) .font(.system(size: 11, weight: .semibold)) .foregroundStyle(colour) .padding(.horizontal, 10) .padding(.vertical, 4) .background(colour.opacity(0.15)) .clipShape(Capsule()) } }