Skip to content

Commit 033b1de

Browse files
authored
Merge pull request #158 from synonymdev/feat/tx-detail-outputs
Display outputs in transaction details
2 parents c9a4dfb + db512e5 commit 033b1de

File tree

3 files changed

+169
-13
lines changed

3 files changed

+169
-13
lines changed

Bitkit/Utilities/AddressChecker.swift

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,40 @@ struct AddressInfo: Codable {
1414
let mempool_stats: AddressStats
1515
}
1616

17+
struct TxInput: Codable {
18+
let txid: String?
19+
let vout: Int?
20+
let prevout: TxOutput?
21+
let scriptsig: String?
22+
let scriptsig_asm: String?
23+
let witness: [String]?
24+
let is_coinbase: Bool?
25+
let sequence: Int64?
26+
}
27+
28+
struct TxOutput: Codable {
29+
let scriptpubkey: String
30+
let scriptpubkey_asm: String?
31+
let scriptpubkey_type: String?
32+
let scriptpubkey_address: String?
33+
let value: Int64
34+
let n: Int?
35+
}
36+
37+
struct TxStatus: Codable {
38+
let confirmed: Bool
39+
let block_height: Int?
40+
let block_hash: String?
41+
let block_time: Int64?
42+
}
43+
44+
struct TxDetails: Codable {
45+
let txid: String
46+
let vin: [TxInput]
47+
let vout: [TxOutput]
48+
let status: TxStatus
49+
}
50+
1751
enum AddressCheckerError: Error {
1852
case invalidUrl
1953
case networkError(Error)
@@ -47,4 +81,30 @@ class AddressChecker {
4781
throw AddressCheckerError.networkError(error)
4882
}
4983
}
84+
85+
/// Fetches full transaction details from the Esplora endpoint for the given txid.
86+
/// - Parameter txid: Hex transaction identifier.
87+
static func getTransaction(txid: String) async throws -> TxDetails {
88+
guard let url = URL(string: "\(Env.esploraServerUrl)/tx/\(txid)") else {
89+
throw AddressCheckerError.invalidUrl
90+
}
91+
92+
do {
93+
let (data, response) = try await URLSession.shared.data(from: url)
94+
95+
guard let httpResponse = response as? HTTPURLResponse,
96+
httpResponse.statusCode == 200
97+
else {
98+
throw AddressCheckerError.invalidResponse
99+
}
100+
101+
let decoder = JSONDecoder()
102+
return try decoder.decode(TxDetails.self, from: data)
103+
} catch let error as DecodingError {
104+
Logger.error("decoding error \(error)")
105+
throw AddressCheckerError.invalidResponse
106+
} catch {
107+
throw AddressCheckerError.networkError(error)
108+
}
109+
}
50110
}

Bitkit/Utilities/LocalizeHelpers.swift

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,64 @@ enum LocalizationHelper {
5555
return key
5656
}
5757
}
58+
59+
/// Formats a string using ICU MessageFormat with pluralization support
60+
static func formatPlural(_ pattern: String, arguments: [String: Any], locale: Locale = Locale.current) -> String {
61+
return formatterPlural(pattern, arguments: arguments)
62+
}
63+
64+
// TODO: implement a ICU message format library
65+
/// Fallback pluralization formatter for when ICU MessageFormat isn't available
66+
private static func formatterPlural(_ pattern: String, arguments: [String: Any]) -> String {
67+
var result = pattern
68+
69+
// Handle basic plural syntax: {count, plural, one {...} other {...}}
70+
let pluralRegex = try! NSRegularExpression(pattern: "\\{(\\w+),\\s*plural,\\s*one\\s*\\{([^}]+)\\}\\s*other\\s*\\{([^}]+)\\}\\}", options: [])
71+
72+
let matches = pluralRegex.matches(in: pattern, options: [], range: NSRange(location: 0, length: pattern.count))
73+
74+
for match in matches.reversed() { // Process in reverse to maintain string indices
75+
let fullMatchRange = match.range
76+
let countVarRange = match.range(at: 1)
77+
let oneFormRange = match.range(at: 2)
78+
let otherFormRange = match.range(at: 3)
79+
80+
let countVarName = String(pattern[Range(countVarRange, in: pattern)!])
81+
let oneForm = String(pattern[Range(oneFormRange, in: pattern)!])
82+
let otherForm = String(pattern[Range(otherFormRange, in: pattern)!])
83+
84+
if let countValue = arguments[countVarName] {
85+
let count: Int = if let intValue = countValue as? Int {
86+
intValue
87+
} else if let doubleValue = countValue as? Double {
88+
Int(doubleValue)
89+
} else if let stringValue = countValue as? String, let intValue = Int(stringValue) {
90+
intValue
91+
} else {
92+
0
93+
}
94+
95+
let selectedForm = (count == 1) ? oneForm : otherForm
96+
var processedForm = selectedForm.replacingOccurrences(of: "#", with: "\(count)")
97+
98+
// Replace other variables in the selected form
99+
for (key, value) in arguments {
100+
if key != countVarName {
101+
processedForm = processedForm.replacingOccurrences(of: "{\(key)}", with: "\(value)")
102+
}
103+
}
104+
105+
result = result.replacingCharacters(in: Range(fullMatchRange, in: result)!, with: processedForm)
106+
}
107+
}
108+
109+
// Replace any remaining simple variables
110+
for (key, value) in arguments {
111+
result = result.replacingOccurrences(of: "{\(key)}", with: "\(value)")
112+
}
113+
114+
return result
115+
}
58116
}
59117

60118
// MARK: - Public API
@@ -71,6 +129,11 @@ func t(_ key: String, comment: String = "", variables: [String: String] = [:]) -
71129
return localizedString
72130
}
73131

132+
func tPlural(_ key: String, comment: String = "", arguments: [String: Any] = [:]) -> String {
133+
let localizedString = LocalizationHelper.getString(for: key, comment: comment)
134+
return LocalizationHelper.formatPlural(localizedString, arguments: arguments)
135+
}
136+
74137
// These are for keys that are not yet translated
75138
func tTodo(_ key: String, comment: String = "", variables: [String: String] = [:]) -> String {
76139
var localizedString = key

Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ struct ActivityExplorerView: View {
88
@EnvironmentObject var app: AppViewModel
99
@EnvironmentObject var currency: CurrencyViewModel
1010

11+
@State private var txDetails: TxDetails?
12+
1113
private var onchain: OnchainActivity? {
1214
guard case let .onchain(activity) = item else { return nil }
1315
return activity
@@ -32,6 +34,19 @@ struct ActivityExplorerView: View {
3234
return URL(string: "\(baseUrl)/tx/\(txId)")
3335
}
3436

37+
private func loadTransactionDetails() async {
38+
guard let onchain else { return }
39+
40+
do {
41+
let details = try await AddressChecker.getTransaction(txid: onchain.txId)
42+
await MainActor.run {
43+
txDetails = details
44+
}
45+
} catch {
46+
Logger.warn("Failed to load transaction details: \(error)")
47+
}
48+
}
49+
3550
private var amountPrefix: String {
3651
switch item {
3752
case let .lightning(activity):
@@ -101,22 +116,35 @@ struct ActivityExplorerView: View {
101116
content: onchain.txId,
102117
)
103118

104-
InfoSection(
105-
title: "INPUT",
106-
content: "\(onchain.txId):0",
107-
)
119+
if let txDetails {
120+
CaptionText(tPlural("wallet__activity_input", arguments: ["count": txDetails.vin.count]))
121+
.textCase(.uppercase)
122+
.padding(.bottom, 8)
123+
VStack(alignment: .leading, spacing: 4) {
124+
ForEach(Array(txDetails.vin.enumerated()), id: \.offset) { _, input in
125+
let txId = input.txid ?? ""
126+
let vout = input.vout ?? 0
127+
BodySSBText("\(txId):\(vout)")
128+
.lineLimit(1)
129+
.truncationMode(.middle)
130+
}
131+
}
132+
133+
Divider()
134+
.padding(.vertical, 16)
108135

109-
CaptionText("OUTPUTS (2)")
110-
.textCase(.uppercase)
111-
.padding(.bottom, 8)
112-
VStack(alignment: .leading, spacing: 4) {
113-
ForEach(0 ..< 2, id: \.self) { i in
114-
BodySSBText("bcrt1q...output\(i)")
115-
.lineLimit(1)
116-
.truncationMode(.middle)
136+
CaptionText(tPlural("wallet__activity_output", arguments: ["count": txDetails.vout.count]))
137+
.textCase(.uppercase)
138+
.padding(.bottom, 8)
139+
VStack(alignment: .leading, spacing: 4) {
140+
ForEach(txDetails.vout.indices, id: \.self) { i in
141+
BodySSBText(txDetails.vout[i].scriptpubkey_address ?? "")
142+
.lineLimit(1)
143+
.truncationMode(.middle)
144+
}
117145
}
146+
.padding(.bottom, 16)
118147
}
119-
.padding(.bottom, 16)
120148

121149
Divider()
122150
.padding(.bottom, 16)
@@ -156,6 +184,11 @@ struct ActivityExplorerView: View {
156184
.navigationBarHidden(true)
157185
.padding(.horizontal, 16)
158186
.bottomSafeAreaPadding()
187+
.task {
188+
if onchain != nil {
189+
await loadTransactionDetails()
190+
}
191+
}
159192
}
160193
}
161194

0 commit comments

Comments
 (0)