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