TransactionTypeDetail.swift (16739B)
1 /* 2 * This file is part of GNU Taler, ©2022-26 Taler Systems S.A. 3 * See LICENSE.md 4 */ 5 /** 6 * @author Marc Stibane 7 */ 8 import SwiftUI 9 import taler_swift 10 import SymLog 11 12 13 struct TransactionTypeDetail: View { 14 private let symLog = SymLogV(0) 15 let stack: CallStack 16 @Binding var transaction: TalerTransaction 17 @Binding var payNow: Bool 18 @Binding var selectedChoice: Int? 19 @Binding var scope: ScopeInfo? 20 @Binding var effective: Amount? 21 let hasDone: Bool 22 23 @EnvironmentObject private var controller: Controller 24 @EnvironmentObject private var model: WalletModel 25 @Environment(\.colorScheme) private var colorScheme 26 @Environment(\.colorSchemeContrast) private var colorSchemeContrast 27 @AppStorage("minimalistic") var minimalistic: Bool = false 28 #if DEBUG 29 @AppStorage("developerMode") var developerMode: Bool = true 30 #else 31 @AppStorage("developerMode") var developerMode: Bool = false 32 #endif 33 @State private var rotationEnabled = true 34 @State private var ignoreAccept: Bool = false // accept could be set by OIM to trigger accept 35 36 func refreshFee(input: Amount, output: Amount) -> Amount? { 37 do { 38 let fee = try input - output 39 return fee 40 } catch { 41 42 } 43 return nil 44 } 45 46 func abortedHint(_ delay: Duration?) -> UInt? { 47 if let delay { 48 if let microseconds = try? delay.microseconds() { 49 let days = microseconds / (24 * 3600 * 1000 * 1000) 50 if days > 0 { 51 return UInt(days) 52 } 53 } 54 return 0 55 } 56 return nil 57 } 58 59 var body: some View { 60 #if PRINT_CHANGES 61 let _ = Self._printChanges() 62 let _ = symLog.vlog() 63 #endif 64 let common = transaction.common 65 let pending = transaction.isPending 66 let isDialog = transaction.isDialog 67 Group { 68 switch transaction { 69 case .dummy(_): Group { 70 let title = EMPTYSTRING 71 Text(title) 72 .talerFont(.body) 73 RotatingTaler(size: 100, progress: true, once: false, rotationEnabled: $rotationEnabled) 74 .frame(maxWidth: .infinity, alignment: .center) 75 // has its own accessibilityLabel 76 } 77 case .withdrawal(let withdrawalTransaction): Group { 78 let details = withdrawalTransaction.details 79 if common.isAborted && details.withdrawalDetails.type == .manual { 80 if let days = abortedHint(details.withdrawalDetails.reserveClosingDelay) { 81 let wireBack = days > 0 ? String(localized: "If you have already sent money to the payment service, it will wire it back in \(days) days.") 82 : String(localized: "If you have already sent money to the payment service, it will wire it back in a few days.") 83 Text("The withdrawal was aborted.\n\n\(wireBack)") 84 .talerFont(.callout) 85 } 86 } 87 if pending { 88 PendingWithdrawalDetails(stack: stack.push(), 89 transaction: $transaction, 90 details: details) 91 } // ManualDetails or Confirm now (with bank) 92 ThreeAmountsSheet(stack: stack.push(), 93 scope: scope, 94 common: common, 95 topAbbrev: String(localized: "Chosen:", comment: "mini"), 96 topTitle: String(localized: "Chosen amount to withdraw:"), 97 baseURL: details.exchangeBaseUrl, 98 noFees: nil, // TODO: noFees 99 feeIsNegative: true, 100 large: false, 101 summary: nil) 102 if developerMode { 103 let withdrawalDetails = details.withdrawalDetails 104 if let validDetails = withdrawalDetails.exchangeCreditAccountDetails?.validDetails { 105 let validCount = validDetails.count 106 if validCount > 0 { 107 ForEach(validDetails, id: \.self) { detail in 108 let label = detail.bankLabel ?? EMPTYSTRING 109 if let options = detail.transferOptions, !options.isEmpty { 110 ForEach(options, id: \.self) { option in 111 if let uri = option.uri { 112 CopyButton(uri, title: "Copy \(label) uri") 113 } else if let payto = option.paytoUri { 114 CopyButton(payto, title: "Copy \(label) payto") 115 } 116 } } } } } } 117 } 118 case .deposit(let depositTransaction): Group { 119 if transaction.common.isPendingKYCauth { 120 KYCauth(stack: stack.push(), common: common) 121 } else if transaction.isPendingKYC { 122 KYCbutton(kycUrl: common.kycUrl) 123 } 124 ThreeAmountsSheet(stack: stack.push(), 125 scope: scope, 126 common: common, 127 topAbbrev: String(localized: "Deposit:", comment: "mini"), 128 topTitle: String(localized: "Amount to deposit:"), 129 baseURL: nil, // TODO: baseURL 130 noFees: nil, // TODO: noFees 131 feeIsNegative: false, 132 large: true, 133 summary: nil) 134 } 135 136 case .payment(let paymentTransaction): 137 PaymentTransactionView(stack: stack.push(), 138 common: common, 139 paymentTransaction: paymentTransaction, 140 scope: $scope, 141 effective: $effective, 142 payNow: $payNow, 143 selectedChoice: $selectedChoice) 144 .task(id: common.txState.hashValue) { 145 let state = common.txState 146 if common.isDialog { 147 let choicesTuple = controller.choicesTuple 148 let choicesForPayment = choicesTuple.1 149 if choicesTuple.0 != common.transactionId || choicesForPayment == nil { 150 symLog.log("❗️❗️ task: \(common.txState)") 151 await controller.loadChoicesForPayment(stack.push(),model, 152 txId: common.transactionId) 153 } else { 154 symLog.log("❗️❗️ choicesForPayment exists, no need for task: \(state)") 155 } 156 } else { 157 symLog.log("❗️❗️ \(common.type.rawValue), no need for task: \(state)") 158 } 159 } 160 case .refund(let refundTransaction): Group { 161 let details = refundTransaction.details // TODO: more details, details.info?.merchant.name 162 ThreeAmountsSheet(stack: stack.push(), 163 scope: scope, 164 common: common, 165 topAbbrev: String(localized: "Refunded:", comment: "mini"), 166 topTitle: String(localized: "Refunded amount:"), 167 baseURL: nil, // TODO: baseURL 168 noFees: nil, // TODO: noFees 169 feeIsNegative: true, 170 large: true, 171 summary: details.paymentInfo?.summary) 172 } 173 case .refresh(let refreshTransaction): Group { 174 let labelColor = WalletColors().labelColor 175 let errorColor = WalletColors().errorColor 176 let details = refreshTransaction.details 177 Section { 178 Text(details.refreshReason.localizedRefreshReason) 179 .talerFont(.title) 180 let input = details.refreshInputAmount 181 AmountRowV(stack: stack.push(), 182 title: minimalistic ? "Refreshed:" : "Refreshed amount:", 183 amount: input, 184 scope: scope, 185 isNegative: nil, 186 color: labelColor, 187 large: true) 188 if let fee = refreshFee(input: input, output: details.refreshOutputAmount) { 189 AmountRowV(stack: stack.push(), 190 title: minimalistic ? "Fee:" : "Refreshed fee:", 191 amount: fee, 192 scope: scope, 193 isNegative: fee.isZero ? nil : true, 194 color: labelColor, 195 large: true) 196 } 197 if let error = details.error { 198 HStack { 199 VStack(alignment: .leading) { 200 Text(error.hint) 201 .talerFont(.headline) 202 .foregroundColor(errorColor) 203 .listRowSeparator(.hidden) 204 if let stack = error.stack { 205 Text(stack) 206 .talerFont(.body) 207 .foregroundColor(errorColor) 208 .listRowSeparator(.hidden) 209 } 210 } 211 let stackStr = error.stack ?? EMPTYSTRING 212 let errorStr = error.hint + "\n" + stackStr 213 CopyButton(errorStr, vertical: true) 214 .accessibilityLabel(Text("Copy the error", comment: "a11y")) 215 .disabled(false) 216 } 217 } 218 } 219 } 220 221 case .peer2peer(let p2pTransaction): Group { 222 let details = p2pTransaction.details 223 if transaction.isPendingKYC { 224 KYCbutton(kycUrl: common.kycUrl) 225 } 226 if !transaction.isDone { 227 ExpiresView(expiration: details.info.expiration) 228 } 229 if transaction.isRcvCoins && common.isDialog { 230 PeerPushCreditView(stack: stack.push(), 231 raw: common.amountRaw, 232 effective: common.amountEffective, 233 isDone: common.isDone, 234 scope: scope, 235 summary: details.info.summary) 236 PeerPushCreditAccept(stack: stack.push(), url: nil, 237 transactionId: common.transactionId, 238 accept: $ignoreAccept) 239 } else if transaction.isPayInvoice && common.isDialog { 240 PeerPullDebitView(stack: stack.push(), 241 raw: common.amountRaw, 242 effective: common.amountEffective, 243 isDone: common.isDone, 244 scope: scope, 245 summary: details.info.summary) 246 PeerPullDebitConfirm(stack: stack.push(), url: nil, 247 transactionId: common.transactionId) 248 } else { 249 // TODO: isSendCoins should show QR only while not yet expired - either set timer or wallet-core should do so and send a state-changed notification 250 // TODO: details.info.summary 251 if pending { 252 if transaction.isPendingReady { 253 QRCodeDetails(transaction: transaction) 254 if hasDone { 255 Text("QR code and link can also be scanned or copied / shared from Transactions later.") 256 .multilineTextAlignment(.leading) 257 .talerFont(.subheadline) 258 // .padding(.top) 259 } 260 } else { 261 Text("This transaction is not yet ready...") 262 .multilineTextAlignment(.leading) 263 .talerFont(.subheadline) 264 } 265 } 266 let colon = ":" 267 let localizedType = transaction.isDone ? transaction.localizedTypePast 268 : transaction.localizedType 269 ThreeAmountsSheet(stack: stack.push(), 270 scope: scope, 271 common: common, 272 topAbbrev: localizedType + colon, 273 topTitle: localizedType + colon, 274 baseURL: details.exchangeBaseUrl, 275 noFees: nil, // TODO: noFees 276 feeIsNegative: true, 277 large: false, 278 summary: details.info.summary) 279 } // else 280 } // p2p 281 282 case .recoup(let recoupTransaction): Group { 283 let details = recoupTransaction.details // TODO: details.recoupReason 284 ThreeAmountsSheet(stack: stack.push(), 285 scope: scope, 286 common: common, 287 topAbbrev: String(localized: "Recoup:", comment: "mini"), 288 topTitle: String(localized: "Recoup:"), 289 baseURL: nil, // TODO: baseURL, noFees 290 noFees: nil, 291 feeIsNegative: nil, 292 large: true, 293 summary: details.recoupReason) 294 } 295 case .denomLoss(let denomLossTransaction): Group { 296 let details = denomLossTransaction.details // TODO: more details, details.lossEventType.rawValue 297 ThreeAmountsSheet(stack: stack.push(), 298 scope: scope, 299 common: common, 300 topAbbrev: String(localized: "Lost:", comment: "mini"), 301 topTitle: String(localized: "Money lost:"), 302 baseURL: details.exchangeBaseUrl, 303 noFees: nil, // TODO: noFees 304 feeIsNegative: nil, 305 large: true, 306 summary: details.lossEventType.rawValue) 307 } 308 } // switch 309 } // Group 310 } 311 }