From 021672b47cb06d08abba0fa7799f725f24a85360 Mon Sep 17 00:00:00 2001 From: TZGyn Date: Sun, 20 Sep 2026 00:43:09 +0800 Subject: [PATCH] Show full transaction details for Monero and XRP --- apps/ios/Koins/Bridge/KoinsMoneroWallet.mm | 44 ++++-- apps/ios/Koins/Models.swift | 10 ++ apps/ios/Koins/Views/DashboardViews.swift | 13 +- apps/ios/Koins/Views/RootView.swift | 13 +- .../Koins/Views/TransactionDetailView.swift | 147 ++++++++++++++++++ apps/ios/Koins/Wallet/MoneroService.swift | 9 +- apps/ios/Koins/Wallet/XRPWalletService.swift | 5 +- 7 files changed, 228 insertions(+), 13 deletions(-) create mode 100644 apps/ios/Koins/Views/TransactionDetailView.swift diff --git a/apps/ios/Koins/Bridge/KoinsMoneroWallet.mm b/apps/ios/Koins/Bridge/KoinsMoneroWallet.mm index 7fba617..21e2e58 100644 --- a/apps/ios/Koins/Bridge/KoinsMoneroWallet.mm +++ b/apps/ios/Koins/Bridge/KoinsMoneroWallet.mm @@ -209,15 +209,41 @@ static void connectDaemon(monero_wallet_full *w) { const auto &tx = transfer->m_tx; const auto isIn = transfer->is_incoming(); boost::optional txHeight = tx ? tx->get_height() : boost::none; - [entries addObject:@{ - @"hash" : [NSString stringWithUTF8String:((tx && tx->m_hash) ? *tx->m_hash : "").c_str()], - @"amount" : @((transfer->m_amount ? *transfer->m_amount : 0)).stringValue, - @"timestamp" : @((tx && tx->m_received_timestamp) ? *tx->m_received_timestamp : 0), - @"direction" : (isIn && *isIn) ? @"in" : @"out", - @"confirmed" : @((tx && tx->m_is_confirmed) ? *tx->m_is_confirmed : false), - @"height" : @(txHeight ? *txHeight : 0), - @"fee" : @((tx && tx->m_fee) ? *tx->m_fee : 0).stringValue, - }]; + + NSMutableDictionary *entry = [NSMutableDictionary dictionary]; + entry[@"hash"] = [NSString stringWithUTF8String:((tx && tx->m_hash) ? *tx->m_hash : "").c_str()]; + entry[@"amount"] = @((transfer->m_amount ? *transfer->m_amount : 0)).stringValue; + entry[@"timestamp"] = @((tx && tx->m_received_timestamp) ? *tx->m_received_timestamp : 0); + entry[@"direction"] = (isIn && *isIn) ? @"in" : @"out"; + entry[@"confirmed"] = @((tx && tx->m_is_confirmed) ? *tx->m_is_confirmed : false); + entry[@"height"] = @(txHeight ? *txHeight : 0); + entry[@"fee"] = @((tx && tx->m_fee) ? *tx->m_fee : 0).stringValue; + + // Extra detail for the transaction view. + if (tx) { + if (tx->m_payment_id) entry[@"paymentId"] = [NSString stringWithUTF8String:tx->m_payment_id->c_str()]; + if (tx->m_num_confirmations) entry[@"confirmations"] = @(*tx->m_num_confirmations); + if (tx->m_weight) entry[@"weight"] = @(*tx->m_weight); + if (tx->m_note) entry[@"note"] = [NSString stringWithUTF8String:tx->m_note->c_str()]; + if (tx->m_unlock_time) entry[@"unlockTime"] = @(*tx->m_unlock_time); + } + if (transfer->m_account_index) entry[@"accountIndex"] = @(*transfer->m_account_index); + + if (isIn && *isIn) { + auto incoming = std::dynamic_pointer_cast(transfer); + if (incoming) { + if (incoming->m_subaddress_index) entry[@"subaddressIndex"] = @(*incoming->m_subaddress_index); + if (incoming->m_address) entry[@"counterparty"] = [NSString stringWithUTF8String:incoming->m_address->c_str()]; + } + } else { + auto outgoing = std::dynamic_pointer_cast(transfer); + if (outgoing) { + if (!outgoing->m_addresses.empty()) entry[@"counterparty"] = [NSString stringWithUTF8String:outgoing->m_addresses.front().c_str()]; + if (!outgoing->m_subaddress_indices.empty()) entry[@"subaddressIndex"] = @(outgoing->m_subaddress_indices.front()); + } + } + + [entries addObject:entry]; } } catch (const std::exception &e) { os_log_error(OS_LOG_DEFAULT, "[koins] getTransactions: %{public}s", e.what()); diff --git a/apps/ios/Koins/Models.swift b/apps/ios/Koins/Models.swift index 2b79983..05a6d49 100644 --- a/apps/ios/Koins/Models.swift +++ b/apps/ios/Koins/Models.swift @@ -32,6 +32,16 @@ struct TransactionRecord: Identifiable { var fee: String? var tag: Int? var counterparty: String? + var confirmations: UInt64? + var height: UInt64? + var paymentId: String? + var accountIndex: Int? + var subaddressIndex: Int? + var note: String? + var weight: UInt64? + var ledgerIndex: UInt64? + var sequence: UInt64? + } struct Balance { diff --git a/apps/ios/Koins/Views/DashboardViews.swift b/apps/ios/Koins/Views/DashboardViews.swift index d40d364..c7a2300 100644 --- a/apps/ios/Koins/Views/DashboardViews.swift +++ b/apps/ios/Koins/Views/DashboardViews.swift @@ -7,6 +7,7 @@ struct DashboardView: View { @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) { @@ -64,7 +65,12 @@ struct DashboardView: View { .font(.system(size: 13, weight: .semibold)) .foregroundStyle(Theme.muted) ForEach(model.transactions) { record in - TransactionRow(record: record, symbol: model.symbol) + Button { + selectedTransaction = record + } label: { + TransactionRow(record: record, symbol: model.symbol) + } + .buttonStyle(.plain) if record.id != model.transactions.last?.id { Divider().overlay(Theme.border) } @@ -96,6 +102,11 @@ struct DashboardView: View { 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 } diff --git a/apps/ios/Koins/Views/RootView.swift b/apps/ios/Koins/Views/RootView.swift index 4336271..69f57b4 100644 --- a/apps/ios/Koins/Views/RootView.swift +++ b/apps/ios/Koins/Views/RootView.swift @@ -71,6 +71,7 @@ struct HomeView: View { @EnvironmentObject private var model: AppModel @Binding var route: Route @State private var showDeleteConfirm = false + @State private var selectedTransaction: TransactionRecord? var body: some View { VStack(spacing: 18) { @@ -91,6 +92,11 @@ struct HomeView: View { MessageBanner(message: message, isError: false) } } + .sheet(item: $selectedTransaction) { record in + TransactionDetailView(record: record, + symbol: model.symbol, + chain: model.chain) { selectedTransaction = nil } + } .confirmationDialog("Delete this wallet?", isPresented: $showDeleteConfirm, titleVisibility: .visible) { @@ -203,7 +209,12 @@ struct HomeView: View { .font(.system(size: 13, weight: .semibold)) .foregroundStyle(Theme.muted) ForEach(model.transactions) { record in - TransactionRow(record: record, symbol: model.symbol) + Button { + selectedTransaction = record + } label: { + TransactionRow(record: record, symbol: model.symbol) + } + .buttonStyle(.plain) if record.id != model.transactions.last?.id { Divider().overlay(Theme.border) } diff --git a/apps/ios/Koins/Views/TransactionDetailView.swift b/apps/ios/Koins/Views/TransactionDetailView.swift new file mode 100644 index 0000000..9339935 --- /dev/null +++ b/apps/ios/Koins/Views/TransactionDetailView.swift @@ -0,0 +1,147 @@ +import SwiftUI + +/// Full detail for one transaction. Monero and XRP fill different fields, so +/// rows are included only when the wallet knows the value. +struct TransactionDetailView: View { + var record: TransactionRecord + var symbol: String + var chain: Chain + var onDone: () -> Void + + private var dateFormatter: DateFormatter { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .medium + return formatter + } + + /// A Monero incoming transfer only reveals the wallet's own receiving + /// address (the sender is hidden), so it is not labelled "From". + private var counterpartyLabel: String { + if chain == .xmr, record.direction == .incoming { return "Received at" } + return record.direction == .incoming ? "From" : "To" + } + + private var statusText: String { + if record.direction == .incoming { + if let confirmations = record.confirmations, confirmations > 0 { + return "Confirmed · \(confirmations) confirmation\(confirmations == 1 ? "" : "s")" + } + return record.confirmed ? "Confirmed" : "Waiting for confirmation" + } + if let confirmations = record.confirmations, confirmations > 0 { + return "Confirmed · \(confirmations) confirmation\(confirmations == 1 ? "" : "s")" + } + return record.confirmed ? "Confirmed" : "Pending" + } + + var body: some View { + ZStack { + Theme.background.ignoresSafeArea() + ScrollView { + VStack(alignment: .leading, spacing: 18) { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Image(systemName: record.direction == .incoming ? "arrow.down.left" : "arrow.up.right") + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(record.direction == .incoming ? Theme.success : Theme.accent) + Text(record.direction == .incoming ? "Received" : "Sent") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(Theme.text) + } + Text("\(record.direction == .incoming ? "+" : "-")\(record.amount) \(symbol)") + .font(.system(size: 30, weight: .bold, design: .rounded)) + .foregroundStyle(Theme.text) + Text(statusText) + .font(.system(size: 12)) + .foregroundStyle(Theme.muted) + } + + Card { + VStack(alignment: .leading, spacing: 12) { + if let timestamp = record.timestamp { + DetailRow(label: "Date", value: dateFormatter.string(from: timestamp)) + } + if let fee = record.fee, !fee.isEmpty, record.direction == .outgoing { + DetailRow(label: "Network fee", value: "\(fee) \(symbol)") + } + if let counterparty = record.counterparty, !counterparty.isEmpty { + DetailRow(label: counterpartyLabel, value: counterparty, monospaced: true, copyable: true) + } + if let tag = record.tag { + DetailRow(label: "Destination tag", value: String(tag)) + } + if let height = record.height ?? record.ledgerIndex { + DetailRow(label: chain == .xmr ? "Block height" : "Ledger index", value: String(height)) + } + if let confirmations = record.confirmations { + DetailRow(label: "Confirmations", value: String(confirmations)) + } + if let accountIndex = record.accountIndex { + DetailRow(label: "Account", value: accountIndex == 0 ? "Primary (0)" : "Account \(accountIndex + 1) (\(accountIndex))") + } + if let subaddressIndex = record.subaddressIndex { + DetailRow(label: "Address index", value: "\(subaddressIndex)") + } + if let sequence = record.sequence { + DetailRow(label: "Sequence", value: String(sequence)) + } + if let paymentId = record.paymentId { + DetailRow(label: "Payment ID", value: paymentId, monospaced: true, copyable: true) + } + if let weight = record.weight { + DetailRow(label: "Weight", value: "\(weight) bytes") + } + if let note = record.note { + DetailRow(label: "Note", value: note) + } + DetailRow(label: chain == .xmr ? "Transaction ID" : "Ledger hash", + value: record.id, + monospaced: true, + copyable: true) + } + } + + PrimaryButton(title: "Done", action: onDone) + } + .padding(20) + } + } + .preferredColorScheme(.dark) + } +} + +struct DetailRow: View { + var label: String + var value: String + var monospaced = false + var copyable = false + + @State private var copied = false + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Text(label) + .font(.system(size: 12)) + .foregroundStyle(Theme.muted) + .frame(width: 108, alignment: .leading) + Text(value) + .font(.system(size: 12, design: monospaced ? .monospaced : .default)) + .foregroundStyle(Theme.text) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + if copyable { + Button { + UIPasteboard.general.string = value + copied = true + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { copied = false } + } label: { + Image(systemName: copied ? "checkmark" : "doc.on.doc") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(Theme.accent) + } + } + } + } +} \ No newline at end of file diff --git a/apps/ios/Koins/Wallet/MoneroService.swift b/apps/ios/Koins/Wallet/MoneroService.swift index 516ae36..db7a380 100644 --- a/apps/ios/Koins/Wallet/MoneroService.swift +++ b/apps/ios/Koins/Wallet/MoneroService.swift @@ -192,7 +192,14 @@ final class MoneroService { confirmed: (entry["confirmed"] as? Bool) ?? false, fee: MoneroAmount.xmr(fromAtomic: entry["fee"] as? String ?? "0"), tag: nil, - counterparty: nil) + counterparty: entry["counterparty"] as? String, + confirmations: (entry["confirmations"] as? NSNumber)?.uint64Value, + height: (entry["height"] as? NSNumber)?.uint64Value, + paymentId: (entry["paymentId"] as? String).flatMap { $0.isEmpty ? nil : $0 }, + accountIndex: (entry["accountIndex"] as? NSNumber)?.intValue, + subaddressIndex: (entry["subaddressIndex"] as? NSNumber)?.intValue, + note: (entry["note"] as? String).flatMap { $0.isEmpty ? nil : $0 }, + weight: (entry["weight"] as? NSNumber)?.uint64Value) } } diff --git a/apps/ios/Koins/Wallet/XRPWalletService.swift b/apps/ios/Koins/Wallet/XRPWalletService.swift index 9ac82e3..99f758c 100644 --- a/apps/ios/Koins/Wallet/XRPWalletService.swift +++ b/apps/ios/Koins/Wallet/XRPWalletService.swift @@ -87,7 +87,10 @@ final class XRPWalletService { confirmed: (entry["validated"] as? Bool) ?? false, fee: (tx["Fee"] as? String).map { XRPAmount.xrp(fromDrops: $0) }, tag: (tx["DestinationTag"] as? NSNumber)?.intValue, - counterparty: isIncoming ? (tx["Account"] as? String) : destination)) + counterparty: isIncoming ? (tx["Account"] as? String) : destination, + height: (entry["ledger_index"] as? NSNumber)?.uint64Value, + ledgerIndex: (entry["ledger_index"] as? NSNumber)?.uint64Value, + sequence: (tx["Sequence"] as? NSNumber)?.uint64Value)) } return records }