TransactionSummaryList.swift (30133B)
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 MerchantHeader: View { 29 let terms: MerchantContractTerms? 30 31 func summary(_ terms: MerchantContractTerms) -> String { 32 if let i18nDict = terms.summaryI18n { 33 if !i18nDict.isEmpty { 34 for code in Locale.preferredLanguageCodes { 35 if let descI18n = i18nDict[code] { 36 return descI18n 37 } 38 } 39 } 40 } 41 return terms.summary 42 } 43 44 var body: some View { 45 if let terms { 46 Section { 47 Text(summary(terms)) 48 .talerFont(.title3) 49 } header: { 50 HStack { 51 Spacer() 52 VStack(alignment: .center) { 53 if let imageBase64 = terms.merchant.logo { 54 if let url = NSURL(string: imageBase64) { 55 if let data = NSData(contentsOf: url as URL) { 56 if let uiImage = UIImage(data: data as Data) { 57 Image(uiImage: uiImage) 58 .resizable() 59 .aspectRatio(contentMode: .fit) 60 .frame(maxHeight: 60) 61 } 62 } 63 } 64 } else { 65 #if TALER_NIGHTLY 66 let imageName = if #available(iOS 17.0, *) { MERCHANT17 } else { MERCHANT14 } 67 Image(systemName: imageName) 68 .resizable() 69 .frame(width: 44, height: 44) 70 #endif 71 } 72 let merchant = terms.merchant.name 73 Text(merchant) 74 .talerFont(.title3) 75 }.foregroundStyle(Color(.primary)) 76 Spacer() 77 } 78 } 79 } 80 } 81 } 82 // MARK: - 83 struct PaymentTransactionView: View { 84 private let symLog = SymLogV() 85 let stack: CallStack 86 let common: TransactionCommon 87 let paymentTransaction: PaymentTransaction 88 @Binding var scope: ScopeInfo? 89 @Binding var effective: Amount? 90 @Binding var payNow: Bool 91 @Binding var isLoadingChoices: Bool? 92 @Binding var selectedChoice: Int 93 94 @EnvironmentObject private var model: WalletModel 95 @State var choicesForPayment: GetChoicesForPaymentResult? = nil 96 // @State var isLoadingChoices: Bool? = nil 97 98 @MainActor 99 func choiceTriple() -> ([ChoiceTriple], Bool)? { 100 if let choicesForPayment { 101 let choices = choicesForPayment.choices 102 let terms = choicesForPayment.contractTerms 103 if let ctChoices = terms.choices { 104 let combined = Array(zip(choices, ctChoices, ctChoices.indices)) 105 return (combined, true) 106 } else if let amount = terms.amount { // V0 107 let maxFee = terms.maxFee ?? Amount.zero(currency: amount.currencyStr) 108 let ctChoice = ContractChoice(amount: amount, 109 maxFee: maxFee, 110 description: terms.summary, 111 descriptionI18n: terms.summaryI18n, 112 inputs: [], 113 outputs: []) 114 let combined = Array(zip(choices, [ctChoice], [0])) 115 return (combined, false) 116 } else { 117 symLog.log(" ❗️Yikes, neither choices nor amount in contractTerms!\n\(stack)") 118 } 119 } 120 return nil 121 } 122 123 private func getChoicesForPayment() async { 124 if isLoadingChoices == nil { 125 symLog.log("first getChoicesForPayment, stack: \(stack)") 126 isLoadingChoices = false 127 } 128 if isLoadingChoices == false { 129 isLoadingChoices = true 130 let txId = common.transactionId 131 if let choiceResponse = try? await model.getChoicesForPayment(txId) { 132 choicesForPayment = choiceResponse 133 isLoadingChoices = false 134 } 135 } else { 136 symLog.log("getChoicesForPayment already in progress") 137 } 138 } 139 140 func summary(_ info: OrderShortInfo?) -> String? { 141 if let i18nDict = info?.summary_i18n { 142 if !i18nDict.isEmpty { 143 for code in Locale.preferredLanguageCodes { 144 if let i18n = i18nDict[code] { 145 return i18n 146 } 147 } 148 } 149 } 150 if let summary = info?.summary { 151 return summary 152 } 153 return String(localized: "No summary", comment: "OrderShortInfo.summary") 154 } 155 156 var body: some View { 157 let _ = Self._printChanges() 158 let _ = symLog.vlog() 159 Group { 160 let details = paymentTransaction.details 161 if common.isDialog { // show payment confirmation dialog 162 MerchantHeader(terms: details.contractTerms) 163 164 if let (choices, showHeader) = choiceTriple() { 165 let hasAutomatic = choicesForPayment?.automaticExecution ?? false 166 let automaticIndex = hasAutomatic ? choicesForPayment?.automaticExecutableIndex : nil 167 ChoicesView(stack: stack.push(), 168 choiceTriple: choices, 169 showHeader: showHeader, 170 automaticIndex: automaticIndex, 171 selectedChoice: $selectedChoice) 172 .onChange(of: selectedChoice) { newValue in 173 let newChoice = choices[newValue] 174 effective = newChoice.0.amountEffective 175 scope = newChoice.0.scopeInfo 176 } 177 .task { 178 let firstChoice = choices[selectedChoice] 179 effective = firstChoice.0.amountEffective 180 scope = firstChoice.0.scopeInfo 181 if let automaticIndex { 182 // Pay Automatically 183 selectedChoice = automaticIndex 184 payNow = true 185 } 186 } 187 188 let choice = choices[selectedChoice] 189 let selectionDetail: ChoiceSelectionDetail = choice.0 190 let contractChoice: ContractChoice = choice.1 191 192 // if selectionDetail.status == .paymentPossible { 193 PaymentView2(stack: stack.push(), // TODO: details.info.merchant.name 194 paid: false, 195 raw: selectionDetail.amountRaw, 196 effective: effective, 197 firstScope: scope, 198 baseURL: nil, 199 summary: summary(details.info), 200 products: details.info?.products ?? [], 201 balanceDetails: selectionDetail.balanceDetails) 202 // } 203 } 204 } else { // show finished payment 205 TransactionPayDetailV(paymentTx: paymentTransaction) // TODO: details.info.merchant.name 206 ThreeAmountsSheet(stack: stack.push(), 207 scope: scope, 208 common: common, 209 topAbbrev: String(localized: "Price:", comment: "mini"), 210 topTitle: String(localized: "Price (net):"), 211 baseURL: nil, // TODO: baseURL 212 noFees: nil, // TODO: noFees 213 feeIsNegative: false, 214 large: true, 215 summary: details.info?.summary ?? EMPTYSTRING) 216 } // show finished payment 217 } 218 .task(id: common.txState.hashValue) { 219 let state = common.txState 220 if common.isDialog { 221 if choicesForPayment == nil { 222 symLog.log("❗️❗️ task: \(common.txState)") 223 await getChoicesForPayment() 224 } else { 225 symLog.log("❗️❗️ choicesForPayment exists, no need for task: \(state)") 226 } 227 } else { 228 symLog.log("❗️❗️ \(common.type.rawValue), no need for task: \(state)") 229 } 230 } 231 } // body 232 } // PaymentTransactionView 233 // MARK: - 234 struct TransactionSummaryList: View { 235 private let symLog = SymLogV(0) 236 let stack: CallStack 237 let transactionId: String 238 @Binding var talerTX: TalerTransaction 239 let navTitle: String? 240 let hasDone: Bool 241 let showDone: TalerButtonStyleType? 242 let url: URL? // the scanned talerURL from PaymentView 243 let withActions: Bool 244 245 @EnvironmentObject private var controller: Controller 246 @EnvironmentObject private var model: WalletModel 247 @Environment(\.colorScheme) private var colorScheme 248 @Environment(\.colorSchemeContrast) private var colorSchemeContrast 249 @Environment(\.dismiss) var dismiss // call dismiss() to pop back 250 @AppStorage("minimalistic") var minimalistic: Bool = false 251 @AppStorage("myListStyle") var myListStyle: MyListStyle = .automatic 252 #if DEBUG 253 @AppStorage("developerMode") var developerMode: Bool = true 254 #else 255 @AppStorage("developerMode") var developerMode: Bool = false 256 #endif 257 258 @State private var currencyInfo: CurrencyInfo = CurrencyInfo.zero(UNKNOWN) 259 @State private var isCopied: Bool = false 260 @State private var ignoreThis: Bool = false 261 @State private var didDelete: Bool = false 262 @State var jsonTransaction: String = EMPTYSTRING 263 @State var viewId = UUID() 264 @State private var selectedChoice: Int = 0 265 @State private var effective: Amount? = nil 266 @State private var scope: ScopeInfo? = nil 267 @State private var payNow: Bool = false 268 @Namespace var topID 269 270 func loadTransaction() async { 271 if let reloadedTransaction = try? await model.getTransactionById(transactionId, 272 includeContractTerms: true, viewHandles: false) { 273 symLog.log("reloaded \(reloadedTransaction.localizedType): \(reloadedTransaction.common.txState.major)") 274 withAnimation { 275 talerTX = reloadedTransaction; 276 scope = reloadedTransaction.common.scopes.first 277 viewId = UUID() // redraw 278 } 279 if developerMode { 280 if let json = try? await model.jsonTransactionById(transactionId, 281 includeContractTerms: true, viewHandles: false) { 282 jsonTransaction = json 283 } else { 284 jsonTransaction = EMPTYSTRING 285 } 286 } 287 } else { 288 withAnimation{ talerTX = TalerTransaction(dummyCurrency: DEMOCURRENCY); viewId = UUID() } 289 jsonTransaction = EMPTYSTRING 290 } 291 } 292 293 private func payTransaction() async { 294 if let confirmPayResult = try? await model.confirmPay(transactionId, 295 choiceIndex: selectedChoice) { 296 // symLog.log(confirmPayResult as Any) 297 if confirmPayResult.type == "done" { 298 if let url { 299 controller.removeURL(url) 300 } 301 // paymentDone = true 302 } else { 303 if let url { 304 controller.removeURL(url) // TODO: pending might fail - in which case we might want to try again 305 } 306 // paymentPending = true 307 } 308 } 309 } 310 311 @MainActor 312 @discardableResult 313 func checkDismiss(_ notification: Notification, _ logStr: String = EMPTYSTRING) -> Bool { 314 if hasDone { 315 if let transition = notification.userInfo?[TRANSACTIONTRANSITION] as? TransactionTransition { 316 if transition.transactionId == talerTX.common.transactionId { // is the transition for THIS transaction? 317 symLog.log(logStr) 318 if talerTX.common.type.isPayment { 319 checkReload(notification, logStr) 320 } else { 321 dismissTop(stack.push()) // if this view is in a sheet then dissmiss the sheet 322 return true 323 } 324 } 325 } 326 } else { // no sheet but the details view -> reload 327 checkReload(notification, logStr) 328 } 329 return false 330 } 331 332 @MainActor 333 private func dismiss(_ stack: CallStack) { 334 if hasDone { // if this view is in a sheet then dissmiss the whole sheet 335 dismissTop(stack.push()) 336 } else { // on a NavigationStack just pop 337 dismiss() 338 } 339 } 340 341 func checkReload(_ notification: Notification, _ logStr: String = EMPTYSTRING) { 342 if let transition = notification.userInfo?[TRANSACTIONTRANSITION] as? TransactionTransition { 343 if transition.transactionId == transactionId { // is the transition for THIS transaction? 344 let newMajor = transition.newTxState.major 345 Task { // runs on MainActor 346 // flush the screen first, then reload 347 withAnimation { talerTX = TalerTransaction(dummyCurrency: DEMOCURRENCY); viewId = UUID() } 348 symLog.log("newState: \(newMajor), reloading transaction") 349 if newMajor != .none { // don't reload after delete 350 await loadTransaction() 351 } 352 } 353 } 354 } else { // Yikes - should never happen 355 // TODO: logger.warning("Can't get notification.userInfo as TransactionTransition") 356 symLog.log(notification.userInfo as Any) 357 } 358 } 359 360 func localizedState(_ txState: TransactionState) -> String { 361 if let minorState = txState.minor { 362 if developerMode { return minorState.localizedDbgState } 363 if talerTX.isPayment { 364 // return String(localized: "Payment", comment: "TxMajorState heading") 365 } 366 return minorState.localizedState ?? txState.major.localizedState 367 } 368 return txState.major.localizedState 369 } 370 371 @ViewBuilder 372 func dateAndStatus(_ common: TransactionCommon) -> some View { 373 let (dateString, date) = TalerDater.dateString(common.timestamp, minimalistic) 374 let a11yDate = TalerDater.accessibilityDate(date) ?? dateString 375 Text(dateString) 376 .talerFont(.body) 377 .accessibilityLabel(a11yDate) 378 .foregroundColor(WalletColors().secondary(colorScheme, colorSchemeContrast)) 379 .id(topID) 380 let state = localizedState(common.txState) 381 let statusT = Text(state) 382 .multilineTextAlignment(.trailing) 383 let imageT = Text(common.type.icon()) 384 .accessibilityHidden(true) 385 HStack(alignment: .center, spacing: HSPACING) { 386 imageT 387 Spacer(minLength: 0) 388 statusT 389 } 390 if developerMode { 391 if !jsonTransaction.isEmpty { 392 CopyButton(textToCopy: jsonTransaction, isCopied: $isCopied, title: "Copy JSON") 393 } 394 } 395 } 396 397 @ViewBuilder 398 func suspendResume(_ common: TransactionCommon) -> some View { 399 if talerTX.isSuspendable { 400 TransactionButton(transactionId: common.transactionId, 401 command: .suspend, 402 warning: nil, 403 didExecute: $ignoreThis, 404 action: model.suspendTransaction) 405 .listRowSeparator(.hidden) 406 } 407 if talerTX.isResumable { 408 TransactionButton(transactionId: common.transactionId, 409 command: .resume, 410 warning: nil, 411 didExecute: $ignoreThis, 412 action: model.resumeTransaction) 413 .listRowSeparator(.hidden) 414 } 415 } 416 417 @ViewBuilder 418 func abortFailDelete(_ common: TransactionCommon) -> some View { 419 if talerTX.isAbortable { 420 let warning = String(localized: "Are you sure you want to abort this transaction?") 421 TransactionButton(transactionId: common.transactionId, 422 command: .abort, 423 warning: warning, 424 didExecute: $ignoreThis, 425 action: model.abortTransaction) 426 } // Abort button 427 if talerTX.isFailable { 428 let warning = String(localized: "Are you sure you want to abandon this transaction?") 429 TransactionButton(transactionId: common.transactionId, 430 command: .fail, 431 warning: warning, 432 didExecute: $ignoreThis, 433 action: model.failTransaction) 434 } // Fail button 435 if talerTX.isDeleteable { 436 let warning = String(localized: "Are you sure you want to delete this transaction?") 437 TransactionButton(transactionId: common.transactionId, 438 command: .delete, 439 warning: warning, 440 didExecute: $didDelete, 441 action: model.deleteTransaction) 442 .onChange(of: didDelete) { wasDeleted in 443 if wasDeleted { 444 symLog.log("wasDeleted -> dismiss view") 445 dismiss(stack) 446 } 447 } 448 } // Delete button 449 } 450 451 var body: some View { 452 //#if PRINT_CHANGES 453 let _ = Self._printChanges() 454 let _ = symLog.vlog() // just to get the # to compare it with .onAppear & onDisappear 455 //#endif 456 let common = talerTX.common 457 // let scope = common.scopes.first // might be nil if scopes == [] 458 let locale = TalerDater.shared.locale 459 let isPaying = talerTX.isPayment && talerTX.isDialog 460 let navTitle2 = talerTX.isDone ? talerTX.localizedTypePast 461 : isPaying ? String(localized: "Confirm Payment", comment:"pay merchant navTitle") 462 : talerTX.localizedType 463 Group { 464 if common.type != .dummy && transactionId == common.transactionId { 465 let list = List { 466 if developerMode && withActions { suspendResume(common) } 467 if !isPaying { 468 dateAndStatus(common) 469 .listRowSeparator(.hidden) 470 .talerFont(.title) 471 } 472 TransactionTypeDetail(stack: stack.push(), 473 transaction: $talerTX, 474 payNow: $payNow, 475 selectedChoice: $selectedChoice, 476 scope: $scope, 477 effective: $effective, 478 hasDone: hasDone) 479 480 // TODO: Retry Countdown, Retry Now button 481 // if talerTX.isRetryable, let retryAction { 482 // TransactionButton(transactionId: common.transactionId, command: .retry, 483 // warning: nil, action: abortAction) 484 // } // Retry button 485 if withActions { abortFailDelete(common) } 486 }.id(viewId) // change viewId to enforce a draw update 487 .listStyle(myListStyle.style).anyView 488 .navigationBarBackButtonHidden(hasDone) 489 .interactiveDismissDisabled(hasDone) // can only use "Done" button to dismiss 490 .safeAreaInset(edge: .bottom) { 491 if isPaying, case .payment(let paymentTransaction) = talerTX { 492 let details = paymentTransaction.details 493 if let effective, let url, let terms = details.contractTerms { 494 let formatted = effective.formatted(currencyInfo) 495 PaySafeArea(symLog: symLog, 496 stack: stack.push(), 497 terms: terms, 498 amountString: formatted.0, 499 amountA11y: formatted.1, 500 payNow: $payNow) 501 .onChange(of: payNow) { payNow2 in 502 if payNow2 { 503 Task { 504 payNow = false 505 await payTransaction() 506 } 507 } 508 } 509 } else { 510 Button("Cancel") { dismissTop(stack.push()) } 511 .buttonStyle(TalerButtonStyle(type: .bordered)) 512 .padding(.horizontal) 513 } // Cancel 514 } else if let showDone { 515 Button("Done") { dismissTop(stack.push()) } 516 .buttonStyle(TalerButtonStyle(type: showDone)) 517 .padding(.horizontal) 518 } 519 } 520 .onNotification(.TransactionExpired) { notification in 521 // TODO: Alert user that this tx just expired 522 if checkDismiss(notification, "newTxState.major == expired => dismiss sheet") { 523 // TODO: logger.info("newTxState.major == expired => dismiss sheet") 524 } 525 } 526 .onNotification(.TransactionDone) { notification in 527 checkDismiss(notification, "newTxState.major == done => dismiss sheet") 528 } 529 .onNotification(.DismissSheet) { notification in 530 checkDismiss(notification, "exchangeWaitReserve or withdrawCoins => dismiss sheet") 531 } 532 .onNotification(.PendingReady) { notification in 533 checkReload(notification, "pending ready ==> reload for talerURI") 534 } 535 .onNotification(.TransactionStateTransition) { notification in 536 if !didDelete { 537 checkReload(notification, "some transition ==> reload") 538 } 539 } 540 .navigationTitle(navTitle ?? navTitle2) 541 542 if #available(iOS 17.0, *) { 543 list.toolbarTitleDisplayMode(.inlineLarge) 544 } else { 545 list 546 } 547 548 549 } else { 550 Color.clear 551 .frame(maxWidth: .infinity, maxHeight: .infinity) 552 .task { 553 symLog.log("task - load transaction") 554 await loadTransaction() 555 } 556 } // else 557 } // Group 558 .onChange(of: scope) { newVal in 559 if let newVal { 560 currencyInfo = controller.info(for: newVal) ?? CurrencyInfo.zero(UNKNOWN) 561 } 562 } 563 .onAppear { 564 symLog.log("onAppear") 565 DebugViewC.shared.setViewID(VIEW_TRANSACTIONSUMMARY, stack: stack.push()) 566 } 567 .onDisappear { 568 symLog.log("onDisappear") 569 } 570 } 571 } // TransactionSummaryList 572 // MARK: - 573 struct KYCbutton: View { 574 let kycUrl: String? 575 576 var body: some View { 577 if let kycUrl { 578 if let destination = URL(string: kycUrl) { 579 LinkButton(destination: destination, 580 hintTitle: String(localized: "You need to pass a legitimization procedure.", comment: "KYC"), 581 buttonTitle: String(localized: "Open legitimization website", comment: "KYC"), 582 a11yHint: String(localized: "Will go to legitimization website to permit this withdrawal.", comment: "a11y"), 583 badge: NEEDS_KYC) 584 } 585 } 586 } 587 } 588 // MARK: - 589 struct PendingWithdrawalDetails: View { 590 let stack: CallStack 591 @Binding var transaction: TalerTransaction 592 let details: WithdrawalTransactionDetails 593 594 var body: some View { 595 let common = transaction.common 596 if transaction.isPendingKYC { 597 if let kycUrl = common.kycUrl { 598 KYCbutton(kycUrl: common.kycUrl) 599 } else { 600 Text("Legitimization procedure required", comment: "KYC") 601 } 602 } 603 let withdrawalDetails = details.withdrawalDetails 604 switch withdrawalDetails.type { 605 case .manual: // "Make a wire transfer of \(amount) to" 606 ManualDetailsV(stack: stack.push(), common: common, details: withdrawalDetails) 607 608 case .bankIntegrated: // "Authorize now" (with bank) 609 if !transaction.isPendingKYC { // cannot authorize if KYC is needed first 610 let confirmed = withdrawalDetails.confirmed ?? false 611 if !confirmed { 612 if let confirmationUrl = withdrawalDetails.bankConfirmationUrl { 613 if let destination = URL(string: confirmationUrl) { 614 LinkButton(destination: destination, 615 hintTitle: String(localized: "The bank is waiting for your authorization."), 616 buttonTitle: String(localized: "Authorize now"), 617 a11yHint: String(localized: "Will go to bank website to authorize this withdrawal.", comment: "a11y"), 618 badge: CONFIRM_BANK) 619 } } } } 620 @unknown default: 621 ErrorView(stack.push(), 622 title: "Unknown withdrawal type", // should not happen, so no L10N 623 message: withdrawalDetails.type.rawValue, 624 copyable: true) { 625 dismissTop(stack.push()) 626 } 627 } // switch 628 } 629 } 630 // MARK: - 631 struct QRCodeDetails: View { 632 var transaction : TalerTransaction 633 var body: some View { 634 let details = transaction.detailsToShow() 635 let keys = details.keys 636 if keys.contains(TALERURI) { 637 if let talerURI = details[TALERURI] { 638 if talerURI.count > 10 { 639 QRCodeDetailView(talerURI: talerURI, 640 talerCopyShare: talerURI, 641 incoming: transaction.isP2pIncoming, 642 amount: transaction.common.amountRaw, 643 scope: transaction.common.scopes.first) 644 // scopes shouldn't (- but might) be nil! 645 } 646 } 647 } else if keys.contains(EXCHANGEBASEURL) { 648 if let baseURL = details[EXCHANGEBASEURL] { 649 Text("from \(baseURL.trimURL)", comment: "baseURL") 650 .talerFont(.title2) 651 .padding(.bottom) 652 } 653 } 654 } 655 } 656 // MARK: - 657 #if DEBUG 658 //struct TransactionSummary_Previews: PreviewProvider { 659 // static func deleteTransactionDummy(transactionId: String) async throws {} 660 // static func doneActionDummy() {} 661 // static var withdrawal = TalerTransaction(incoming: true, 662 // pending: true, 663 // id: "some withdrawal ID", 664 // time: Timestamp(from: 1_666_000_000_000)) 665 // static var payment = TalerTransaction(incoming: false, 666 // pending: false, 667 // id: "some payment ID", 668 // time: Timestamp(from: 1_666_666_000_000)) 669 // static func reloadActionDummy(transactionId: String) async -> TalerTransaction { return withdrawal } 670 // static var previews: some View { 671 // Group { 672 // TransactionSummaryV(transaction: withdrawal, reloadAction: reloadActionDummy, doneAction: doneActionDummy) 673 // TransactionSummaryV(transaction: payment, reloadAction: reloadActionDummy) 674 // } 675 // } 676 //} 677 #endif