765 lines
27 KiB
Swift
765 lines
27 KiB
Swift
import SwiftUI
|
|
import UIKit
|
|
|
|
/// Swap between the two wallets via kyc.rip, the public no-KYC aggregator.
|
|
struct SwapView: View {
|
|
@EnvironmentObject private var model: AppModel
|
|
@EnvironmentObject private var drawers: DrawerCoordinator
|
|
@Binding var route: Route
|
|
|
|
@State private var from: Chain = .xrp
|
|
@State private var amount = ""
|
|
@State private var estimate: SwapEstimate?
|
|
@State private var selectedRouteID: String?
|
|
@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 xmrAccountIndex = 0
|
|
@State private var xmrAccountPicked = false
|
|
@State private var confirmRemoveOrder = false
|
|
|
|
private var to: Chain { from == .xrp ? .xmr : .xrp }
|
|
|
|
private var selectedXmrAccount: MoneroAccount? {
|
|
model.moneroAccounts.first(where: { $0.index == xmrAccountIndex }) ?? model.moneroAccounts.first
|
|
}
|
|
|
|
private var fromBalance: Balance {
|
|
if from == .xmr, let account = selectedXmrAccount {
|
|
return Balance(total: account.balance,
|
|
unlocked: account.unlocked,
|
|
funded: (Decimal(string: account.balance) ?? 0) > 0)
|
|
}
|
|
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.numberStyle = .decimal
|
|
formatter.usesGroupingSeparator = false
|
|
// Monero has 12 decimals; rounding a balance up here would make "Max"
|
|
// exceed what the account can spend.
|
|
formatter.maximumFractionDigits = from == .xmr ? 12 : 6
|
|
return formatter.string(from: value as NSDecimalNumber) ?? "0"
|
|
}
|
|
|
|
/// Quoted routes worth considering, best payout first.
|
|
private var routes: [SwapRoute] {
|
|
(estimate?.routes ?? []).filter { $0.amountTo > 0 }.sorted { $0.amountTo > $1.amountTo }
|
|
}
|
|
|
|
private var selectedRoute: SwapRoute? {
|
|
routes.first { $0.id == selectedRouteID } ?? routes.first
|
|
}
|
|
|
|
/// One-line summary of everything kyc.rip knows about a route.
|
|
private func routeFacts(_ route: SwapRoute) -> String {
|
|
var facts: [String] = []
|
|
if let eta = route.eta { facts.append("~\(eta) min") }
|
|
facts.append(route.fixedRate ? "fixed rate" : "floating rate")
|
|
if let spread = route.spread { facts.append(String(format: "spread %+.2f%%", spread)) }
|
|
if let kyc = route.kycRating { facts.append("KYC \(kyc)") }
|
|
if let log = route.logPolicy { facts.append("logs \(log)") }
|
|
if let insurance = route.insurance, insurance > 0 { facts.append("insured \(insurance)%") }
|
|
if let min = route.minDeposit { facts.append("min \(trim(min)) \(from.symbol)") }
|
|
if let max = route.maxDeposit { facts.append("max \(trim(max)) \(from.symbol)") }
|
|
return facts.joined(separator: " · ")
|
|
}
|
|
|
|
private func rowFacts(_ route: SwapRoute) -> String {
|
|
var facts: [String] = []
|
|
if let spread = route.spread { facts.append(String(format: "%+.2f%%", spread)) }
|
|
if let kyc = route.kycRating { facts.append("KYC \(kyc)") }
|
|
facts.append(route.fixedRate ? "fixed" : "float")
|
|
return facts.joined(separator: " · ")
|
|
}
|
|
|
|
private func trim(_ value: Double) -> String {
|
|
String(format: "%.6g", value)
|
|
}
|
|
|
|
// MARK: - Swap status
|
|
|
|
private func statusTitle(_ status: String) -> String {
|
|
switch status.uppercased() {
|
|
case "WAITING", "NEW": return "Waiting for your deposit"
|
|
case "CONFIRMING": return "Deposit detected"
|
|
case "EXCHANGING": return "Exchanging"
|
|
case "SENDING": return "Sending to your wallet"
|
|
case "FINISHED", "SUCCESS": return "Swap complete"
|
|
case "EXPIRED": return "Order expired"
|
|
case "FAILED": return "Swap failed"
|
|
case "REFUNDED": return "Refunded"
|
|
case "OVERDUE": return "Deposit overdue"
|
|
default: return "Swap status"
|
|
}
|
|
}
|
|
|
|
private func statusDetail(_ status: String, order: SwapOrder) -> String {
|
|
switch status.uppercased() {
|
|
case "WAITING", "NEW":
|
|
return "Send your \(order.fromTicker) deposit to the address below."
|
|
case "CONFIRMING":
|
|
return "Your \(order.fromTicker) arrived. Waiting for network confirmations."
|
|
case "EXCHANGING":
|
|
return "The provider is converting \(order.fromTicker) to \(order.toTicker)."
|
|
case "SENDING":
|
|
return "The provider is sending \(order.toTicker) to your wallet."
|
|
case "FINISHED", "SUCCESS":
|
|
return "\(order.toTicker) was sent to your wallet."
|
|
case "EXPIRED":
|
|
return "The deposit window closed. If you already sent funds, contact \(order.provider) with the order ID."
|
|
case "FAILED":
|
|
return "The provider could not complete this swap. Check the order with \(order.provider)."
|
|
case "REFUNDED":
|
|
return "The provider returned your funds to the refund address."
|
|
case "OVERDUE":
|
|
return "The deposit arrived after the window closed. Check the order with \(order.provider)."
|
|
default:
|
|
return "Last known status from \(order.provider)."
|
|
}
|
|
}
|
|
|
|
/// Position in the five-step lifecycle, or nil for failure states.
|
|
private func statusStep(_ status: String) -> Int? {
|
|
switch status.uppercased() {
|
|
case "WAITING", "NEW": return 0
|
|
case "CONFIRMING": return 1
|
|
case "EXCHANGING": return 2
|
|
case "SENDING": return 3
|
|
case "FINISHED", "SUCCESS": return 4
|
|
default: return nil
|
|
}
|
|
}
|
|
|
|
private func isTerminal(_ status: String?) -> Bool {
|
|
guard let status else { return false }
|
|
return ["FINISHED", "SUCCESS", "FAILED", "EXPIRED", "REFUNDED", "OVERDUE"]
|
|
.contains(status.uppercased())
|
|
}
|
|
|
|
private func needsDeposit(_ status: String) -> Bool {
|
|
["WAITING", "NEW"].contains(status.uppercased())
|
|
}
|
|
|
|
/// Refreshes an open order immediately, then every 20 seconds until it
|
|
/// reaches a final state. Cancelled automatically when the view goes away.
|
|
private func monitorOrder() async {
|
|
guard let order = createdOrder else { return }
|
|
if !isTerminal(order.status) {
|
|
createdOrder = await model.refreshSwapStatus(order)
|
|
}
|
|
while !Task.isCancelled {
|
|
try? await Task.sleep(nanoseconds: 20 * 1_000_000_000)
|
|
guard !Task.isCancelled, let current = createdOrder, !isTerminal(current.status) else { return }
|
|
createdOrder = await model.refreshSwapStatus(current)
|
|
}
|
|
}
|
|
|
|
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
|
|
if !xmrAccountPicked { xmrAccountIndex = model.defaultMoneroAccountIndex }
|
|
model.loadSwapOrders()
|
|
}
|
|
.task(id: createdOrder?.id) {
|
|
await monitorOrder()
|
|
}
|
|
.onChange(of: model.moneroAccounts.count) { _ in
|
|
if !xmrAccountPicked { xmrAccountIndex = model.defaultMoneroAccountIndex }
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
|
|
if from == .xmr, model.moneroAccounts.count > 1 {
|
|
MoneroAccountPicker(accounts: model.moneroAccounts,
|
|
selection: $xmrAccountIndex,
|
|
onSelect: { xmrAccountPicked = true })
|
|
}
|
|
|
|
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", selectedRoute?.amountTo ?? estimate.amountTo)) \(to.symbol)")
|
|
.font(.system(size: 22, weight: .bold, design: .rounded))
|
|
.foregroundStyle(Theme.text)
|
|
if let route = selectedRoute {
|
|
Text(routeFacts(route))
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(Theme.muted)
|
|
}
|
|
}
|
|
|
|
if !routes.isEmpty {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Pick a provider")
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundStyle(Theme.muted)
|
|
ForEach(routes.prefix(4)) { route in
|
|
routeRow(route)
|
|
}
|
|
}
|
|
.padding(.top, 6)
|
|
}
|
|
} 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)
|
|
}
|
|
}
|
|
|
|
private func routeRow(_ route: SwapRoute) -> some View {
|
|
let selected = route.id == selectedRoute?.id
|
|
return Button {
|
|
selectedRouteID = route.id
|
|
} label: {
|
|
HStack(spacing: 10) {
|
|
Image(systemName: selected ? "checkmark.circle.fill" : "circle")
|
|
.font(.system(size: 16, weight: .semibold))
|
|
.foregroundStyle(selected ? Theme.accent : Theme.muted)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
HStack(spacing: 6) {
|
|
Text(route.provider)
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.foregroundStyle(Theme.text)
|
|
if route.recommended {
|
|
Text("Recommended")
|
|
.font(.system(size: 9, weight: .bold))
|
|
.foregroundStyle(Theme.accent)
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 2)
|
|
.background(Theme.accentSoft)
|
|
.clipShape(Capsule())
|
|
}
|
|
}
|
|
Text(rowFacts(route))
|
|
.font(.system(size: 10))
|
|
.foregroundStyle(Theme.muted)
|
|
}
|
|
Spacer()
|
|
VStack(alignment: .trailing, spacing: 2) {
|
|
Text(String(format: "%.6f", route.amountTo))
|
|
.font(.system(size: 13, weight: .semibold, design: .rounded))
|
|
.foregroundStyle(Theme.text)
|
|
Text(route.eta.map { "~\($0) min" } ?? "")
|
|
.font(.system(size: 10))
|
|
.foregroundStyle(Theme.muted)
|
|
}
|
|
}
|
|
.padding(10)
|
|
.background(selected ? Theme.accentSoft : Theme.surface)
|
|
.overlay(RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.stroke(selected ? Theme.accent.opacity(0.6) : Theme.border))
|
|
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
// MARK: - The order
|
|
|
|
private func orderCard(_ order: SwapOrder) -> some View {
|
|
let status = order.status ?? "WAITING"
|
|
let alreadySent = order.depositSent == true
|
|
let canSend = needsDeposit(status) && !alreadySent
|
|
return VStack(alignment: .leading, spacing: 14) {
|
|
Card {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack {
|
|
Text(statusTitle(status))
|
|
.font(.system(size: 15, weight: .semibold))
|
|
.foregroundStyle(Theme.text)
|
|
Spacer()
|
|
SwapStatusBadge(status: status)
|
|
}
|
|
|
|
Text(alreadySent && needsDeposit(status)
|
|
? "Deposit sent — waiting for \(order.provider) to see it."
|
|
: statusDetail(status, order: order))
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(Theme.muted)
|
|
|
|
if let step = statusStep(status) {
|
|
HStack(spacing: 4) {
|
|
ForEach(0..<5, id: \.self) { index in
|
|
Capsule()
|
|
.fill(index <= step ? Theme.accent : Theme.surfaceAlt)
|
|
.frame(height: 4)
|
|
}
|
|
}
|
|
.padding(.vertical, 2)
|
|
}
|
|
|
|
if !isTerminal(status) {
|
|
Text("Updates automatically every 20 seconds.")
|
|
.font(.system(size: 10))
|
|
.foregroundStyle(Theme.muted)
|
|
}
|
|
|
|
if needsDeposit(status) {
|
|
Text(verbatim: alreadySent
|
|
? "Your \(order.depositAmount) \(order.fromTicker) deposit was sent to this address:"
|
|
: "Send exactly \(order.depositAmount) \(order.fromTicker) to this address:")
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(Theme.muted)
|
|
.padding(.top, 4)
|
|
|
|
HStack(spacing: 8) {
|
|
Text(order.depositAddress)
|
|
.font(.system(size: 11, design: .monospaced))
|
|
.foregroundStyle(Theme.text)
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
Button {
|
|
UIPasteboard.general.string = order.depositAddress
|
|
} label: {
|
|
Image(systemName: "doc.on.doc")
|
|
.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)
|
|
Button {
|
|
drawers.qr = 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)
|
|
}
|
|
}
|
|
|
|
HStack {
|
|
Text("Provider")
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundStyle(Theme.muted)
|
|
Spacer()
|
|
Text(order.provider)
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(Theme.text)
|
|
}
|
|
|
|
if order.fromAmount > 0 {
|
|
HStack {
|
|
Text("Rate")
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundStyle(Theme.muted)
|
|
Spacer()
|
|
Text("1 \(order.fromTicker) ≈ \(order.toAmount / order.fromAmount, specifier: "%.4f") \(order.toTicker)")
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(Theme.text)
|
|
}
|
|
}
|
|
|
|
if let txIn = order.txIn, !txIn.isEmpty {
|
|
txHashRow("Deposit tx", hash: txIn, ticker: order.fromTicker)
|
|
}
|
|
if let txOut = order.txOut, !txOut.isEmpty {
|
|
txHashRow("Payout tx", hash: txOut, ticker: order.toTicker)
|
|
}
|
|
|
|
HStack {
|
|
Text("Opened")
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundStyle(Theme.muted)
|
|
Spacer()
|
|
Text(order.createdAt, format: Date.FormatStyle(date: .abbreviated, time: .shortened))
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(Theme.text)
|
|
}
|
|
|
|
HStack {
|
|
Text("Order ID")
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundStyle(Theme.muted)
|
|
Spacer()
|
|
Text(order.id)
|
|
.font(.system(size: 11, design: .monospaced))
|
|
.foregroundStyle(Theme.text)
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
.frame(maxWidth: 160, alignment: .trailing)
|
|
Button {
|
|
UIPasteboard.general.string = order.id
|
|
} label: {
|
|
Image(systemName: "doc.on.doc")
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundStyle(Theme.accent)
|
|
}
|
|
}
|
|
|
|
if let supportUrl = order.supportUrl, let url = URL(string: supportUrl) {
|
|
HStack {
|
|
Text("Support")
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundStyle(Theme.muted)
|
|
Spacer()
|
|
Link("Contact \(order.provider)", destination: url)
|
|
.font(.system(size: 12, weight: .semibold))
|
|
.foregroundStyle(Theme.accent)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if canSend, order.fromTicker.uppercased() == "XMR", model.moneroAccounts.count > 1 {
|
|
MoneroAccountPicker(accounts: model.moneroAccounts,
|
|
selection: $xmrAccountIndex,
|
|
onSelect: { xmrAccountPicked = true })
|
|
}
|
|
|
|
if needsDeposit(status) {
|
|
if alreadySent {
|
|
// The deposit was already broadcast; re-sending would double-pay.
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundStyle(Theme.success)
|
|
Text("Deposit sent")
|
|
.font(.system(size: 16, weight: .semibold))
|
|
.foregroundStyle(Theme.text)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 15)
|
|
.background(Theme.surfaceAlt)
|
|
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
|
} else {
|
|
PrimaryButton(title: sendingDeposit ? "Sending…" : "Send deposit", disabled: sendingDeposit) {
|
|
Task { await sendDeposit(order) }
|
|
}
|
|
}
|
|
}
|
|
|
|
SecondaryButton(title: "Check status now") {
|
|
Task { createdOrder = await model.refreshSwapStatus(order) }
|
|
}
|
|
|
|
if let orderPage = URL(string: "https://kyc.rip/swap?id=\(order.id)") {
|
|
LinkButton(title: "View on kyc.rip", systemImage: "globe", url: orderPage)
|
|
}
|
|
|
|
Button {
|
|
confirmRemoveOrder = true
|
|
} label: {
|
|
Text("Remove this order")
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(Theme.muted)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
|
|
if let localError {
|
|
MessageBanner(message: localError, isError: true)
|
|
}
|
|
|
|
Spacer(minLength: 0)
|
|
}
|
|
.alert("Remove this swap from history?", isPresented: $confirmRemoveOrder) {
|
|
Button("Remove", role: .destructive) {
|
|
model.removeSwapOrder(id: order.id)
|
|
createdOrder = nil
|
|
}
|
|
Button("Cancel", role: .cancel) {}
|
|
} message: {
|
|
Text("Only the local record is removed. If you already sent the deposit, \(order.provider) still has the order.")
|
|
}
|
|
}
|
|
|
|
/// A truncated, copyable transaction hash row for the order card, with a
|
|
/// link to the right block explorer when the ticker is known.
|
|
private func txHashRow(_ label: String, hash: String, ticker: String) -> some View {
|
|
HStack {
|
|
Text(label)
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundStyle(Theme.muted)
|
|
Spacer()
|
|
Text(hash)
|
|
.font(.system(size: 11, design: .monospaced))
|
|
.foregroundStyle(Theme.text)
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
.frame(maxWidth: 160, alignment: .trailing)
|
|
Button {
|
|
UIPasteboard.general.string = hash
|
|
} label: {
|
|
Image(systemName: "doc.on.doc")
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundStyle(Theme.accent)
|
|
}
|
|
if let explorer = BlockExplorer.transactionURL(ticker: ticker, hash: hash) {
|
|
Link(destination: explorer) {
|
|
Image(systemName: "safari")
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundStyle(Theme.accent)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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())
|
|
.contextMenu {
|
|
Button(role: .destructive) {
|
|
if createdOrder?.id == order.id { createdOrder = nil }
|
|
model.removeSwapOrder(id: order.id)
|
|
} label: {
|
|
Label("Remove from history", systemImage: "trash")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 routes.isEmpty {
|
|
localError = "No provider offers this swap right now"
|
|
estimate = nil
|
|
} else {
|
|
// Default to the best payout; kyc.rip's "recommended" flag is shown
|
|
// but not preselected because it can be rev-share driven.
|
|
selectedRouteID = routes.first?.id
|
|
}
|
|
} catch {
|
|
localError = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
private func create() async {
|
|
guard let route = selectedRoute, let estimate else { return }
|
|
localError = nil
|
|
// Respect the provider's deposit window so the order can't be created
|
|
// with an amount it would refuse.
|
|
let value = Double(amount) ?? 0
|
|
if let min = route.minDeposit, value < min {
|
|
localError = "\(route.provider) needs at least \(trim(min)) \(from.symbol)"
|
|
return
|
|
}
|
|
if let max = route.maxDeposit, value > max {
|
|
localError = "\(route.provider) takes at most \(trim(max)) \(from.symbol)"
|
|
return
|
|
}
|
|
creating = true
|
|
defer { creating = false }
|
|
do {
|
|
createdOrder = try await model.createSwap(route: route,
|
|
estimateID: estimate.id,
|
|
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, accountIndex: xmrAccountIndex)
|
|
// Record that the deposit left this device so the button can never be
|
|
// pressed twice for the same order.
|
|
var sent = order
|
|
sent.depositSent = true
|
|
model.saveSwapOrder(sent)
|
|
createdOrder = sent
|
|
await model.refresh(force: true)
|
|
createdOrder = await model.refreshSwapStatus(sent)
|
|
} catch {
|
|
localError = error.localizedDescription
|
|
}
|
|
}
|
|
}
|
|
|
|
struct SwapStatusBadge: View {
|
|
var status: String
|
|
|
|
private var colour: Color {
|
|
switch status.uppercased() {
|
|
case "FINISHED", "SUCCESS": return Theme.success
|
|
case "WAITING", "NEW", "CONFIRMING", "EXCHANGING", "SENDING": return Theme.accent
|
|
case "REFUNDED": return Theme.muted
|
|
case "FAILED", "EXPIRED", "OVERDUE": return Theme.danger
|
|
default: return Theme.muted
|
|
}
|
|
}
|
|
|
|
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())
|
|
}
|
|
} |