Show live swap status, explorer links, and full kyc.rip route details

This commit is contained in:
2026-10-26 21:31:41 +08:00
parent 2f70931cba
commit 3ff5385a9b
13 changed files with 759 additions and 150 deletions
+2 -10
View File
@@ -2,11 +2,11 @@ import SwiftUI
struct AccountsView: View {
@EnvironmentObject private var model: AppModel
@EnvironmentObject private var drawers: DrawerCoordinator
@Binding var route: Route
@State private var error: String?
@State private var busy = false
@State private var qrAddress: AddressQR?
@State private var showAccountPrompt = false
@State private var showSubaddressPrompt = false
@State private var pendingLabel = ""
@@ -37,14 +37,6 @@ struct AccountsView: View {
}
}
.task(id: model.moneroReady) { await model.loadAccounts() }
.bottomDrawer(isPresented: Binding(
get: { qrAddress != nil },
set: { if !$0 { qrAddress = nil } }
)) {
if let item = qrAddress {
AddressQRSheet(title: item.title, address: item.address) { qrAddress = nil }
}
}
.alert("Account name", isPresented: $showAccountPrompt) {
TextField("e.g. Savings", text: $pendingLabel)
Button("Create") { createAccount() }
@@ -97,7 +89,7 @@ struct AccountsView: View {
private func addressRow(title: String, address: String, isUsed: Bool, balance: String? = nil) -> some View {
Button {
qrAddress = AddressQR(title: title, address: address)
drawers.qr = AddressQR(title: title, address: address)
} label: {
HStack(spacing: 8) {
VStack(alignment: .leading, spacing: 2) {
+14 -4
View File
@@ -10,6 +10,15 @@ extension View {
}
}
/// One place for the drawers shown above every screen. Attaching them at the
/// root keeps them anchored to the window instead of to scrolled content.
@MainActor
final class DrawerCoordinator: ObservableObject {
@Published var transaction: TransactionRecord?
@Published var qr: AddressQR?
@Published var settings = false
}
private struct BottomDrawerHost<Host: View, Content: View>: View {
let host: Host
@Binding var isPresented: Bool
@@ -55,18 +64,19 @@ private struct BottomDrawerHost<Host: View, Content: View>: View {
content()
.padding(.top, 14)
.padding(.bottom, bottomInset + 16)
.padding(.bottom, 16)
}
.frame(maxWidth: .infinity, alignment: .top)
.frame(maxHeight: maxHeight)
.background(Theme.surface)
.clipShape(UnevenRoundedRectangle(topLeadingRadius: 20, topTrailingRadius: 20, style: .continuous))
.offset(y: dragOffset)
// Push the panel's background down to the physical screen edge without
// taking the host content out of its safe area.
.offset(y: dragOffset + bottomInset)
.gesture(dismissDrag)
.transition(.move(edge: .bottom))
}
}
.ignoresSafeArea()
.animation(.spring(response: 0.32, dampingFraction: 0.86), value: isPresented)
}
@@ -88,4 +98,4 @@ private struct BottomDrawerHost<Host: View, Content: View>: View {
}
}
}
}
}
+68
View File
@@ -51,6 +51,26 @@ struct SecondaryButton: View {
}
}
/// A full-width button that opens a web page, styled like SecondaryButton.
struct LinkButton: View {
var title: String
var systemImage: String = "safari"
var url: URL
var body: some View {
Link(destination: url) {
Label(title, systemImage: systemImage)
.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
@@ -146,6 +166,54 @@ struct ChainPicker: View {
}
}
/// Picks which Monero account a payment leaves from. Each account has its own
/// balance, so "Max" and the wallet's spend checks need the matching index.
struct MoneroAccountPicker: View {
var accounts: [MoneroAccount]
@Binding var selection: Int
var onSelect: (() -> Void)?
private var selected: MoneroAccount? {
accounts.first(where: { $0.index == selection }) ?? accounts.first
}
var body: some View {
VStack(alignment: .leading, spacing: 6) {
FieldLabel(text: "Send from")
Menu {
ForEach(accounts) { account in
Button {
selection = account.index
onSelect?()
} label: {
Text("\(account.title) · \(account.balance) XMR")
}
}
} label: {
HStack(spacing: 10) {
VStack(alignment: .leading, spacing: 2) {
Text(selected?.title ?? "Primary account")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(Theme.text)
Text("\(selected?.unlocked ?? "0") XMR available")
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
Spacer()
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Theme.muted)
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(Theme.surface)
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(Theme.border))
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
}
}
}
struct QRCodeView: View {
var text: String
+23 -15
View File
@@ -3,12 +3,12 @@ import SwiftUI
struct DashboardView: View {
@EnvironmentObject private var model: AppModel
@EnvironmentObject private var drawers: DrawerCoordinator
@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) {
@@ -70,14 +70,14 @@ struct DashboardView: View {
Text("Activity")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.muted)
ForEach(model.transactions) { record in
ForEach(Array(model.transactions.enumerated()), id: \.offset) { index, record in
Button {
selectedTransaction = record
drawers.transaction = record
} label: {
TransactionRow(record: record, symbol: model.symbol)
}
.buttonStyle(.plain)
if record.id != model.transactions.last?.id {
if index < model.transactions.count - 1 {
Divider().overlay(Theme.border)
}
}
@@ -108,16 +108,6 @@ struct DashboardView: View {
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 }
@@ -134,6 +124,8 @@ struct SendView: View {
@State private var amount = ""
@State private var tag = ""
@State private var priority: TransactionPriority = .defaultPriority
@State private var accountIndex = 0
@State private var accountPicked = false
@State private var localError: String?
@State private var status: String?
@State private var showScanner = false
@@ -159,6 +151,12 @@ struct SendView: View {
}
}
if model.chain == .xmr, model.moneroAccounts.count > 1 {
MoneroAccountPicker(accounts: model.moneroAccounts,
selection: $accountIndex,
onSelect: { accountPicked = true })
}
VStack(alignment: .leading, spacing: 6) {
FieldLabel(text: model.chain == .xrp ? "Destination address" : "Recipient address")
KoinsTextField(placeholder: model.chain == .xrp ? "r…" : "4…", text: $destination)
@@ -203,7 +201,11 @@ struct SendView: View {
localError = nil
model.clearMessages()
do {
try await model.send(to: destination, amount: amount, tag: tag, priority: priority)
try await model.send(to: destination,
amount: amount,
tag: tag,
priority: priority,
accountIndex: accountIndex)
amount = ""
tag = ""
route = .dashboard
@@ -239,6 +241,12 @@ struct SendView: View {
photoItem = nil
}
}
.onAppear {
if !accountPicked { accountIndex = model.defaultMoneroAccountIndex }
}
.onChange(of: model.moneroAccounts.count) { _ in
if !accountPicked { accountIndex = model.defaultMoneroAccountIndex }
}
}
/// Fills the form from a scanned code, accepting both plain addresses and
+36 -36
View File
@@ -31,6 +31,7 @@ struct RootView: View {
return .home
}
@State private var seedBox: SeedBox?
@StateObject private var drawers = DrawerCoordinator()
var body: some View {
ZStack {
@@ -63,6 +64,33 @@ struct RootView: View {
.scrollDismissesKeyboard(.interactively)
}
.preferredColorScheme(.dark)
.task { await model.bootstrap() }
.sheet(item: $seedBox) { box in
SeedRevealView(seed: box.seed, symbol: box.symbol) { seedBox = nil }
}
.bottomDrawer(isPresented: Binding(
get: { drawers.transaction != nil },
set: { if !$0 { drawers.transaction = nil } }
)) {
if let record = drawers.transaction {
TransactionDetailView(record: record,
symbol: model.symbol,
chain: model.chain) { drawers.transaction = nil }
}
}
.bottomDrawer(isPresented: Binding(
get: { drawers.qr != nil },
set: { if !$0 { drawers.qr = nil } }
)) {
if let item = drawers.qr {
AddressQRSheet(title: item.title, address: item.address) { drawers.qr = nil }
}
}
.bottomDrawer(isPresented: $drawers.settings) {
SettingsView()
}
.environmentObject(drawers)
// Above the drawers so a lock always hides whatever was on screen.
.overlay {
if model.locked, model.faceIDEnabled {
LockScreen {
@@ -70,10 +98,6 @@ struct RootView: View {
}
}
}
.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 }
}
@@ -90,10 +114,8 @@ struct RootView: View {
struct HomeView: View {
@EnvironmentObject private var model: AppModel
@EnvironmentObject private var drawers: DrawerCoordinator
@Binding var route: Route
@State private var selectedTransaction: TransactionRecord?
@State private var showSettings = false
@State private var qrAddress: AddressQR?
var body: some View {
VStack(spacing: 18) {
@@ -114,28 +136,6 @@ struct HomeView: View {
MessageBanner(message: message, isError: false)
}
}
.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 }
}
}
.bottomDrawer(isPresented: Binding(
get: { qrAddress != nil },
set: { if !$0 { qrAddress = nil } }
)) {
if let item = qrAddress {
AddressQRSheet(title: item.title, address: item.address) { qrAddress = nil }
}
}
.bottomDrawer(isPresented: $showSettings) {
SettingsView()
.environmentObject(model)
}
}
private var header: some View {
@@ -150,7 +150,7 @@ struct HomeView: View {
}
Spacer()
Button {
showSettings = true
drawers.settings = true
} label: {
Image(systemName: "gearshape")
.font(.system(size: 17, weight: .medium))
@@ -209,7 +209,7 @@ struct HomeView: View {
// the front card away to bring the next one up.
AccountStack(accounts: model.moneroAccounts,
fiat: { model.fiatValue(of: $0, chain: .xmr) },
onQR: { qrAddress = AddressQR(title: $0.title, address: $0.address) })
onQR: { drawers.qr = AddressQR(title: $0.title, address: $0.address) })
}
if model.chain == .xmr {
@@ -257,14 +257,16 @@ struct HomeView: View {
Text("Activity")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.muted)
ForEach(model.transactions) { record in
// A Monero transaction can produce several transfer entries with the
// same hash, so index-based identities keep the rows stable.
ForEach(Array(model.transactions.enumerated()), id: \.offset) { index, record in
Button {
selectedTransaction = record
drawers.transaction = record
} label: {
TransactionRow(record: record, symbol: model.symbol)
}
.buttonStyle(.plain)
if record.id != model.transactions.last?.id {
if index < model.transactions.count - 1 {
Divider().overlay(Theme.border)
}
}
@@ -281,8 +283,6 @@ struct HomeView: View {
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")
+5 -3
View File
@@ -2,7 +2,7 @@ import SwiftUI
struct SettingsView: View {
@EnvironmentObject private var model: AppModel
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var drawers: DrawerCoordinator
@State private var showDeleteConfirm = false
private let currencies = ["USD", "MYR", "JPY"]
@@ -10,7 +10,9 @@ struct SettingsView: View {
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
ScreenHeader(title: "Settings", subtitle: "Security and device options", onBack: { dismiss() })
ScreenHeader(title: "Settings",
subtitle: "Security and device options",
onBack: { drawers.settings = false })
Card {
VStack(alignment: .leading, spacing: 10) {
@@ -108,7 +110,7 @@ struct SettingsView: View {
Button("Delete wallet", role: .destructive) {
Task {
await model.deleteWallet()
dismiss()
drawers.settings = false
}
}
Button("Cancel", role: .cancel) {}
+476 -56
View File
@@ -1,23 +1,37 @@
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 qrAddress: AddressQR?
@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)
}
@@ -25,10 +39,127 @@ struct SwapView: View {
private var fromBalanceText: String {
let value = Decimal(string: fromBalance.total) ?? 0
let formatter = NumberFormatter()
formatter.maximumFractionDigits = 6
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",
@@ -47,15 +178,14 @@ struct SwapView: View {
}
.task {
from = model.chain
if !xmrAccountPicked { xmrAccountIndex = model.defaultMoneroAccountIndex }
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 }
}
.task(id: createdOrder?.id) {
await monitorOrder()
}
.onChange(of: model.moneroAccounts.count) { _ in
if !xmrAccountPicked { xmrAccountIndex = model.defaultMoneroAccountIndex }
}
}
@@ -102,6 +232,12 @@ struct SwapView: View {
.accessibilityLabel("Swap direction")
}
if from == .xmr, model.moneroAccounts.count > 1 {
MoneroAccountPicker(accounts: model.moneroAccounts,
selection: $xmrAccountIndex,
onSelect: { xmrAccountPicked = true })
}
Divider().overlay(Theme.border)
HStack {
@@ -116,15 +252,27 @@ struct SwapView: View {
if let estimate {
VStack(alignment: .leading, spacing: 4) {
Text("≈ \(String(format: "%.6f", estimate.amountTo)) \(to.symbol)")
Text("≈ \(String(format: "%.6f", selectedRoute?.amountTo ?? 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" } ?? "")")
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))
@@ -155,56 +303,145 @@ struct SwapView: View {
}
}
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 {
VStack(alignment: .leading, spacing: 14) {
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("Swap deposit")
Text(statusTitle(status))
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Theme.text)
Spacer()
SwapStatusBadge(status: order.status ?? "WAITING")
SwapStatusBadge(status: status)
}
Text("Send exactly \(order.depositAmount, specifier: "%.6g") \(order.fromTicker) to this address:")
Text(alreadySent && needsDeposit(status)
? "Deposit sent — waiting for \(order.provider) to see it."
: statusDetail(status, order: order))
.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))
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)
}
}
.disabled(order.depositAddress.isEmpty)
.padding(.vertical, 2)
}
.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))
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)
}
}
}
@@ -229,15 +466,122 @@ struct SwapView: View {
.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)
}
}
}
}
PrimaryButton(title: sendingDeposit ? "Sending…" : "Send deposit", disabled: sendingDeposit) {
Task { await sendDeposit(order) }
if canSend, order.fromTicker.uppercased() == "XMR", model.moneroAccounts.count > 1 {
MoneroAccountPicker(accounts: model.moneroAccounts,
selection: $xmrAccountIndex,
onSelect: { xmrAccountPicked = true })
}
SecondaryButton(title: "Refresh status") {
Task { _ = await model.refreshSwapStatus(order) }
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 {
@@ -246,6 +590,46 @@ struct SwapView: View {
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 {
@@ -276,6 +660,14 @@ struct SwapView: View {
}
.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")
}
}
}
}
}
@@ -288,9 +680,13 @@ struct SwapView: View {
defer { estimating = false }
do {
estimate = try await model.estimateSwap(amount: amount, from: from, to: to)
if estimate?.routes.isEmpty == true {
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
@@ -298,12 +694,27 @@ struct SwapView: View {
}
private func create() async {
guard let route = estimate?.routes.first else { return }
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, amountFrom: amount, from: from, to: to)
createdOrder = try await model.createSwap(route: route,
estimateID: estimate.id,
amountFrom: amount,
from: from,
to: to)
} catch {
localError = error.localizedDescription
}
@@ -314,8 +725,15 @@ struct SwapView: View {
sendingDeposit = true
defer { sendingDeposit = false }
do {
try await model.sendSwapDeposit(order)
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
}
@@ -326,10 +744,12 @@ 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
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
}
}
@@ -109,6 +109,10 @@ struct TransactionDetailView: View {
}
}
if let explorer = BlockExplorer.transactionURL(chain: chain, hash: record.id) {
LinkButton(title: "View on explorer", url: explorer)
}
PrimaryButton(title: "Done", action: onDone)
}
.padding(20)