TransactionSummaryList.swift (26440B)
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 extension TalerTransaction { // for Dummys 13 init(dummyCurrency: String) { 14 let amount = Amount.zero(currency: dummyCurrency) 15 let now = Timestamp.now() 16 let common = TransactionCommon(type: .dummy, 17 transactionId: EMPTYSTRING, 18 timestamp: now, 19 scopes: [], 20 txState: TransactionState(major: .pending), 21 txActions: [], 22 amountRaw: amount, 23 amountEffective: amount) 24 self = .dummy(DummyTransaction(common: common)) 25 } 26 } 27 // MARK: - 28 struct TransactionSummaryList: View { 29 private let symLog = SymLogV(0) 30 let stack: CallStack 31 let transactionId: String 32 @Binding var talerTX: TalerTransaction 33 let navTitle: String? 34 let hasDone: Bool // false: just old tx, true: the conclusion of an transaction 35 let showDone: TalerButtonStyleType? 36 let url: URL? // the scanned talerURL from PaymentView 37 let withActions: Bool // true if called from pending or history, or refund 38 39 @EnvironmentObject private var controller: Controller 40 @EnvironmentObject private var model: WalletModel 41 @Environment(\.colorScheme) private var colorScheme 42 @Environment(\.colorSchemeContrast) private var colorSchemeContrast 43 @Environment(\.dismiss) var dismiss // call dismiss() to pop back 44 @AppStorage("minimalistic") var minimalistic: Bool = false 45 @AppStorage("myListStyle") var myListStyle: MyListStyle = .automatic 46 @AppStorage("preferredColorScheme") var preferredColorScheme: Int = 0 47 #if DEBUG 48 @AppStorage("developerMode") var developerMode: Bool = true 49 #else 50 @AppStorage("developerMode") var developerMode: Bool = false 51 #endif 52 53 @State private var currencyInfo: CurrencyInfo = CurrencyInfo.zero(UNKNOWN) 54 @State private var ignoreThis: Bool = false 55 @State private var didDelete: Bool = false 56 @State private var jsonTransaction: String = EMPTYSTRING 57 @State private var viewId = UUID() 58 @State private var selectedChoice: Int? = nil 59 @State private var effective: Amount? = nil 60 @State private var paymentPossible: Bool? = nil 61 @State private var scope: ScopeInfo? = nil 62 @State private var isFinished: Bool? = false 63 @State private var payNow: Bool = false 64 @State private var isWorking: Bool = false 65 @Namespace var topID 66 67 func loadTransaction() async { 68 if let reloadedTransaction = try? await model.getTransactionById(transactionId, 69 includeContractTerms: true, viewHandles: false) { 70 symLog.log("reloaded \(reloadedTransaction.localizedType): \(reloadedTransaction.common.txState.major)") 71 withAnimation { 72 talerTX = reloadedTransaction; 73 isWorking = reloadedTransaction.common.isWorking 74 #if DEBUG 75 // controller.playSound(1104, useSystem: true) 76 #endif 77 scope = reloadedTransaction.common.scopes.first 78 } 79 if developerMode { 80 if let json = try? await model.jsonTransactionById(transactionId, 81 includeContractTerms: true, viewHandles: false) { 82 jsonTransaction = json 83 } else { 84 jsonTransaction = EMPTYSTRING 85 } 86 } 87 } else { 88 withAnimation{ 89 talerTX = TalerTransaction(dummyCurrency: DEMOCURRENCY); 90 isWorking = false 91 viewId = UUID() 92 } 93 jsonTransaction = EMPTYSTRING 94 } 95 } 96 97 private func payTransaction() async { 98 if let confirmPayResult = try? await model.confirmPay(transactionId, 99 choiceIndex: selectedChoice) { 100 // symLog.log(confirmPayResult as Any) 101 if confirmPayResult.type == "done" { 102 if let url { 103 controller.removeURL(url) 104 } 105 } else { 106 if let url { 107 controller.removeURL(url) // TODO: pending might fail - in which case we might want to try again 108 } 109 } 110 let txId = confirmPayResult.transactionId 111 if let finalResult = try? await model.waitTransactionState(txId, token: txId, major: .done) { 112 if let matched = finalResult.matched { 113 print(matched) 114 } 115 } 116 } 117 } 118 119 @MainActor 120 @discardableResult 121 func checkDismiss(_ notification: Notification, _ logStr: String = EMPTYSTRING) -> Bool { 122 if let transition = notification.userInfo?[TRANSACTIONTRANSITION] as? TransactionTransition { 123 if transition.transactionId == talerTX.common.transactionId { // is the transition for THIS transaction? 124 if transition.newTxState.major == .deleted { 125 dismissTop(stack.push()) // dissmiss the sheet / the navigation stack 126 return true 127 } 128 if hasDone { 129 symLog.log(logStr) 130 if talerTX.common.type.isPayment { 131 checkReload(stack.push("checkDismiss1"), notification, logStr) 132 } else { 133 dismissTop(stack.push()) // if this view is in a sheet then dissmiss the sheet 134 return true 135 } 136 } else { // no sheet but the details view -> reload 137 checkReload(stack.push("checkDismiss2"), notification, logStr) 138 } 139 } else if let paymentTransaction = talerTX.paymentTransaction, // is it a payment? 140 transition.transactionId == paymentTransaction.details.repurchaseTransactionId { 141 // repurchase detected: reload 142 doReload(stack.push("checkDismiss3"), transition: transition) 143 } 144 } 145 return false 146 } 147 148 @MainActor 149 private func dismiss(_ stack: CallStack) { 150 if hasDone { // if this view is in a sheet then dissmiss the whole sheet 151 dismissTop(stack.push()) 152 } else { // on a NavigationStack just pop 153 dismiss() 154 } 155 } 156 157 func doReload(_ stack: CallStack, transition: TransactionTransition) { 158 Task { // runs on MainActor 159 let newMajor = transition.newTxState.major 160 // flush the screen first, then reload 161 withAnimation { talerTX = TalerTransaction(dummyCurrency: DEMOCURRENCY); viewId = UUID() } 162 if newMajor != .none { // don't reload after delete 163 let message = stack.peek()?.message ?? "?" 164 symLog.log("newState: \(newMajor), reloading transaction, for: \(message)") 165 await loadTransaction() 166 } 167 } 168 } 169 170 func checkReload(_ stack: CallStack, _ notification: Notification, _ logStr: String = EMPTYSTRING) { 171 if let transition: TransactionTransition = notification.userInfo?[TRANSACTIONTRANSITION] as? TransactionTransition { 172 if transition.transactionId == transactionId { // is the transition for THIS transaction? 173 let newMajor = transition.newTxState.major 174 let message = stack.peek()?.message ?? "?" 175 if newMajor == .deleted { 176 symLog.log("newState: \(newMajor), show 'deleted', for: \(message)") 177 let error = transition.errorInfo ?? 178 TalerErrorDetail(code: 7049, // WALLET_MERCHANT_ORDER_NOT_FOUND 179 when: .now(), 180 hint: "Transaction has been deleted") 181 model.setError(WalletBackendError.walletCoreError(error)) 182 } else { 183 doReload(stack, transition: transition) 184 } 185 } 186 } else { // Yikes - should never happen 187 // TODO: logger.warning("Can't get notification.userInfo as TransactionTransition") 188 symLog.log(notification.userInfo as Any) 189 } 190 } 191 192 func localizedState(_ txState: TransactionState) -> String { 193 let major = txState.major 194 if let minor = txState.minor { 195 if developerMode { return minor.localizedDbgState } 196 // if talerTX.isPayment { 197 // return String(localized: "Payment", comment: "TxMajorState heading") 198 // } 199 return minor.localizedState ?? major.localizedState 200 } 201 return major.localizedState 202 } 203 204 @ViewBuilder 205 func dateAndStatus(_ common: TransactionCommon) -> some View { 206 let (dateString, date) = TalerDater.dateString(common.timestamp, minimalistic) 207 let a11yDate = TalerDater.accessibilityDate(date) ?? dateString 208 Text(dateString) 209 .talerFont(.body) 210 .accessibilityLabel(a11yDate) 211 .foregroundColor(WalletColors().secondary2(colorScheme, colorSchemeContrast)) 212 .id(topID) 213 let state = localizedState(common.txState) 214 let statusT = Text(state) 215 .multilineTextAlignment(.trailing) 216 let imageT = Text(common.type.icon()) 217 .accessibilityHidden(true) 218 HStack(alignment: .center, spacing: HSPACING) { 219 imageT 220 Spacer(minLength: 0) 221 statusT 222 } // TODO: a11y for tx icon 223 if developerMode { 224 if !jsonTransaction.isEmpty { 225 CopyButton(jsonTransaction, title: "Copy JSON") 226 } 227 } 228 } 229 230 @ViewBuilder 231 func suspendResume(_ common: TransactionCommon) -> some View { 232 if talerTX.isSuspendable { 233 TransactionButton(transactionId: common.transactionId, 234 command: .suspend, 235 warning: nil, 236 didExecute: $ignoreThis, 237 action: model.suspendTransaction) 238 .listRowSeparator(.hidden) 239 } 240 if talerTX.isResumable { 241 TransactionButton(transactionId: common.transactionId, 242 command: .resume, 243 warning: nil, 244 didExecute: $ignoreThis, 245 action: model.resumeTransaction) 246 .listRowSeparator(.hidden) 247 } 248 } 249 250 @ViewBuilder 251 func abortFailDelete(_ common: TransactionCommon) -> some View { 252 if talerTX.isAbortable { 253 let warning = String(localized: "Are you sure you want to abort this transaction?") 254 TransactionButton(transactionId: common.transactionId, 255 command: .abort, 256 warning: warning, 257 didExecute: $ignoreThis, 258 action: model.abortTransaction) 259 } // Abort button 260 if talerTX.isFailable { 261 let warning = String(localized: "Are you sure you want to abandon this transaction?") 262 TransactionButton(transactionId: common.transactionId, 263 command: .fail, 264 warning: warning, 265 didExecute: $ignoreThis, 266 action: model.failTransaction) 267 } // Fail button 268 if talerTX.isDeleteable { 269 let warning = String(localized: "Are you sure you want to delete this transaction?") 270 TransactionButton(transactionId: common.transactionId, 271 command: .delete, 272 warning: warning, 273 didExecute: $didDelete, 274 action: model.deleteTransaction) 275 .onChange(of: didDelete) { wasDeleted in 276 if wasDeleted { 277 symLog.log("wasDeleted -> dismiss view") 278 dismiss(stack) 279 } 280 } 281 } // Delete button 282 } 283 284 var possibleStr: String { 285 if let possible = paymentPossible { 286 return possible ? String(localized: "Confirm Payment", comment:"pay merchant navTitle") 287 : String(localized: "Payment not possible", comment:"pay merchant navTitle") 288 } 289 return String(localized: "Payment", comment:"pay merchant navTitle") 290 } 291 var navTitleStr: String { 292 let isPaying = talerTX.isPayment && talerTX.isDialog 293 let isRepurchase = talerTX.common.isRepurchase 294 return talerTX.isDone ? talerTX.localizedTypePast 295 : isPaying ? possibleStr 296 : isRepurchase ? String(localized: "Already paid", comment:"repurchase merchant navTitle") 297 : talerTX.localizedType 298 } 299 var is26: Bool { 300 if #available(iOS 26.0, *) { true } else { false } 301 } 302 303 var body: some View { 304 #if PRINT_CHANGES 305 let _ = Self._printChanges() 306 let _ = symLog.vlog() // just to get the # to compare it with .onAppear & onDisappear 307 #endif 308 #if DEBUG 309 let background = developerMode ? Color.red : nil 310 #else 311 let background: Color? = nil 312 #endif 313 let common = talerTX.common 314 // let scope = common.scopes.first // might be nil if scopes == [] 315 let locale = TalerDater.shared.locale 316 let isPayDialog = talerTX.isPayment && talerTX.isDialog 317 Group { 318 if common.type != .dummy && transactionId == common.transactionId { 319 let list = List { 320 if developerMode && withActions { suspendResume(common) } 321 if !isPayDialog { 322 Section { 323 HStack { 324 VStack(alignment: .leading) { 325 dateAndStatus(common) 326 .listRowSeparator(.hidden) 327 .talerFont(.title) 328 } 329 if isWorking { 330 RotatingTaler(size: 80, progress: true, once: false, 331 background: background, 332 rotationEnabled: Binding.constant(true)) 333 .transition(.opacity.combined(with: .scale)) 334 } 335 } 336 } 337 } 338 if !controller.stalledConnection { 339 TransactionTypeDetail(stack: stack.push(), 340 transaction: $talerTX, 341 payNow: $payNow, 342 selectedChoice: $selectedChoice, 343 scope: $scope, 344 effective: $effective, // return amount for "Pay <amount> now" 345 possible: $paymentPossible, 346 hasDone: hasDone) 347 if withActions { abortFailDelete(common) } 348 } 349 }.id(viewId) // change viewId to enforce a draw update 350 .listStyle(myListStyle.style).anyView 351 .background(FullBackground()) 352 .navigationBarBackButtonHidden(hasDone) 353 .interactiveDismissDisabled(hasDone && !isPayDialog) // can only use "Done" button to dismiss 354 .safeAreaInset(edge: .bottom) { 355 let cancelButton = Button("Cancel") { dismissTop(stack.push()) } 356 .buttonStyle(TalerButtonStyle(type: .bordered)) 357 .padding(is26 ? .horizontal : .all) 358 let bottomButton = ZStack { 359 if isPayDialog, case .payment(let paymentTransaction) = talerTX { 360 let details = paymentTransaction.details 361 if let effective, let terms = details.contractTerms, isFinished == false { 362 let formatted = effective.formatted(currencyInfo) 363 PaySafeArea(symLog: symLog, 364 stack: stack.push(), 365 terms: terms, 366 amountString: formatted.0, 367 amountA11y: formatted.1, 368 isFinished: $isFinished, 369 payNow: $payNow) 370 } else { 371 cancelButton 372 } // Cancel 373 } else if let showDone { 374 Button("Done") { dismissTop(stack.push()) } 375 .buttonStyle(TalerButtonStyle(type: showDone)) 376 .padding(is26 ? .horizontal : .all) 377 } 378 } 379 if controller.stalledConnection { 380 VStack { 381 StalledView() 382 cancelButton 383 .padding(.bottom) 384 } 385 .background { 386 SelectedBackground(useColor: false) 387 } 388 .padding(is26 ? .horizontal : .all) 389 .transition(.move(edge: .bottom)) 390 } else { 391 bottomButton 392 .padding(is26 ? .horizontal : .all) 393 .transition(.scale.combined(with: .opacity)) 394 } 395 } 396 .navigationTitle(navTitle ?? navTitleStr) 397 .onDisappear { 398 // the cached choices (payable or not, effective amount) depend on the balance - 399 // drop them, so that opening this payment again asks wallet-core afresh 400 if controller.choicesTuple.0 == transactionId { 401 controller.choicesTuple = (nil, nil) 402 } 403 } 404 .onChange(of: payNow) { payNow2 in 405 if payNow2 { 406 Task { 407 await payTransaction() 408 payNow = false // re-enable "Pay now" if this attempt did not lead to a state transition 409 } 410 } 411 } 412 .onNotification(.TransactionExpired) { notification in 413 // TODO: Alert user that this tx just expired 414 if checkDismiss(notification, "newTxState.major == expired => dismiss sheet") { 415 // TODO: logger.info("newTxState.major == expired => dismiss sheet") 416 } 417 } 418 .onNotification(.TransactionDone) { notification in 419 checkDismiss(notification, "newTxState.major == done => dismiss sheet") 420 } 421 .onNotification(.DismissSheet) { notification in 422 checkDismiss(notification, "exchangeWaitReserve or withdrawCoins => dismiss sheet") 423 } 424 .onNotification(.PendingReady) { notification in 425 checkReload(stack.push("pendingReady"), notification, "pending ready ==> reload for talerURI") 426 } 427 .onNotification(.TransactionStateTransition) { notification in 428 if !didDelete { 429 checkReload(stack.push("TransactionStateTransition"), notification, "some transition ==> reload") 430 } 431 } 432 433 if #available(iOS 17.0, *) { 434 list 435 .scrollContentBackground(preferredColorScheme == 3 ? .hidden : .visible) 436 .toolbarTitleDisplayMode(.inlineLarge) 437 } else { 438 list 439 } 440 } else { 441 Color.clear 442 .frame(maxWidth: .infinity, maxHeight: .infinity) 443 .task { 444 symLog.log("task - load transaction") 445 await loadTransaction() 446 } 447 } // else 448 } // Group 449 .onChange(of: scope) { newVal in 450 if let newVal { 451 currencyInfo = controller.info(for: newVal) ?? CurrencyInfo.zero(UNKNOWN) 452 } 453 } 454 .onAppear { 455 symLog.log("onAppear") 456 DebugViewC.shared.setViewID(VIEW_TRANSACTIONSUMMARY, stack: stack.push()) 457 } 458 .onDisappear { 459 symLog.log("onDisappear") 460 } 461 } 462 } // TransactionSummaryList 463 // MARK: - 464 struct KYCbutton: View { 465 let kycUrl: String? 466 467 var body: some View { 468 if let kycUrl { 469 if let destination = URL(string: kycUrl) { 470 LinkButton(destination: destination, 471 hintTitle: String(localized: "You need to pass a legitimization procedure.", comment: "KYC"), 472 buttonTitle: String(localized: "Open legitimization website", comment: "KYC"), 473 a11yHint: String(localized: "Will go to legitimization website to permit this withdrawal.", comment: "a11y"), 474 badge: NEEDS_KYC) 475 } 476 } 477 } 478 } 479 // MARK: - 480 struct PendingWithdrawalDetails: View { 481 let stack: CallStack 482 @Binding var transaction: TalerTransaction 483 let details: WithdrawalTransactionDetails 484 485 var body: some View { 486 let common = transaction.common 487 if transaction.isPendingKYC { 488 if let kycUrl = common.kycUrl { 489 KYCbutton(kycUrl: common.kycUrl) 490 } else { 491 Text("Legitimization procedure required", comment: "KYC") 492 } 493 } else { 494 let withdrawalDetails = details.withdrawalDetails 495 switch withdrawalDetails.type { 496 case .manual: // "Make a wire transfer of \(amount) to" 497 ManualDetailsV(stack: stack.push(), common: common, details: withdrawalDetails) 498 499 case .bankIntegrated: // "Authorize now" (with bank) 500 if !transaction.isPendingKYC { // cannot authorize if KYC is needed first 501 let confirmed = withdrawalDetails.confirmed ?? false 502 if !confirmed { 503 if let confirmationUrl = withdrawalDetails.bankConfirmationUrl { 504 if let destination = URL(string: confirmationUrl) { 505 LinkButton(destination: destination, 506 hintTitle: String(localized: "The bank is waiting for your authorization."), 507 buttonTitle: String(localized: "Authorize now"), 508 a11yHint: String(localized: "Will go to bank website to authorize this withdrawal.", comment: "a11y"), 509 badge: CONFIRM_BANK) 510 } } } } 511 @unknown default: 512 ErrorView(stack.push(), 513 title: "Unknown withdrawal type", // should not happen, so no L10N 514 message: withdrawalDetails.toJSON(), 515 copyable: true) 516 } // switch 517 } 518 } 519 } 520 // MARK: - 521 struct QRCodeDetails: View { 522 let stack: CallStack 523 var transaction : TalerTransaction 524 var body: some View { 525 let details = transaction.detailsToShow() 526 let keys = details.keys 527 if keys.contains(TALERURI) { 528 if let talerURI = details[TALERURI] { 529 let summary = details["summary"] 530 if talerURI.count > 10 { 531 QRCodeDetailView(stack: stack.push(), 532 talerURI: talerURI, 533 incoming: transaction.isP2pIncoming, 534 amount: transaction.common.amountRaw, 535 scope: transaction.common.scopes.first, 536 subject: summary) 537 // scopes shouldn't (- but might) be nil! 538 } 539 } 540 } else if keys.contains(EXCHANGEBASEURL) { 541 if let baseURL = details[EXCHANGEBASEURL] { 542 Text("from \(baseURL.trimURL)", comment: "baseURL") 543 .talerFont(.title2) 544 .padding(.bottom) 545 } 546 } 547 } 548 } 549 // MARK: - 550 #if DEBUG 551 //struct TransactionSummary_Previews: PreviewProvider { 552 // static func deleteTransactionDummy(transactionId: String) async throws {} 553 // static func doneActionDummy() {} 554 // static var withdrawal = TalerTransaction(incoming: true, 555 // pending: true, 556 // id: "some withdrawal ID", 557 // time: Timestamp(from: 1_666_000_000_000)) 558 // static var payment = TalerTransaction(incoming: false, 559 // pending: false, 560 // id: "some payment ID", 561 // time: Timestamp(from: 1_666_666_000_000)) 562 // static func reloadActionDummy(transactionId: String) async -> TalerTransaction { return withdrawal } 563 // static var previews: some View { 564 // Group { 565 // TransactionSummaryV(transaction: withdrawal, reloadAction: reloadActionDummy, doneAction: doneActionDummy) 566 // TransactionSummaryV(transaction: payment, reloadAction: reloadActionDummy) 567 // } 568 // } 569 //} 570 #endif