TransactionSummaryList.swift (43185B)
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 35 let showDone: TalerButtonStyleType? 36 let url: URL? // the scanned talerURL from PaymentView 37 let withActions: Bool 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 #if DEBUG 47 @AppStorage("developerMode") var developerMode: Bool = true 48 #else 49 @AppStorage("developerMode") var developerMode: Bool = false 50 #endif 51 52 @State private var currencyInfo: CurrencyInfo = CurrencyInfo.zero(UNKNOWN) 53 @State private var isCopied: Bool = false 54 @State private var ignoreThis: Bool = false 55 @State private var didDelete: Bool = false 56 @State var jsonTransaction: String = EMPTYSTRING 57 @State var viewId = UUID() 58 @State private var selectedChoice: Int = 0 59 @State private var effective: Amount? = nil 60 @State private var scope: ScopeInfo? = nil 61 @State private var payNow: Bool = false 62 @Namespace var topID 63 64 func loadTransaction() async { 65 if let reloadedTransaction = try? await model.getTransactionById(transactionId, 66 includeContractTerms: true, viewHandles: false) { 67 symLog.log("reloaded \(reloadedTransaction.localizedType): \(reloadedTransaction.common.txState.major)") 68 withAnimation { talerTX = reloadedTransaction; viewId = UUID() } // redraw 69 if developerMode { 70 if let json = try? await model.jsonTransactionById(transactionId, 71 includeContractTerms: true, viewHandles: false) { 72 jsonTransaction = json 73 } else { 74 jsonTransaction = EMPTYSTRING 75 } 76 } 77 } else { 78 withAnimation{ talerTX = TalerTransaction(dummyCurrency: DEMOCURRENCY); viewId = UUID() } 79 jsonTransaction = EMPTYSTRING 80 } 81 } 82 83 private func payTransaction() async { 84 if let confirmPayResult = try? await model.confirmPay(transactionId, 85 choiceIndex: selectedChoice) { 86 // symLog.log(confirmPayResult as Any) 87 if confirmPayResult.type == "done" { 88 if let url { 89 controller.removeURL(url) 90 } 91 // paymentDone = true 92 } else { 93 if let url { 94 controller.removeURL(url) // TODO: pending might fail - in which case we might want to try again 95 } 96 // paymentPending = true 97 } 98 } 99 } 100 101 @MainActor 102 @discardableResult 103 func checkDismiss(_ notification: Notification, _ logStr: String = EMPTYSTRING) -> Bool { 104 if hasDone { 105 if let transition = notification.userInfo?[TRANSACTIONTRANSITION] as? TransactionTransition { 106 if transition.transactionId == talerTX.common.transactionId { // is the transition for THIS transaction? 107 symLog.log(logStr) 108 if talerTX.common.type.isPayment { 109 checkReload(notification, logStr) 110 } else { 111 dismissTop(stack.push()) // if this view is in a sheet then dissmiss the sheet 112 return true 113 } 114 } 115 } 116 } else { // no sheet but the details view -> reload 117 checkReload(notification, logStr) 118 } 119 return false 120 } 121 122 @MainActor 123 private func dismiss(_ stack: CallStack) { 124 if hasDone { // if this view is in a sheet then dissmiss the whole sheet 125 dismissTop(stack.push()) 126 } else { // on a NavigationStack just pop 127 dismiss() 128 } 129 } 130 131 func checkReload(_ notification: Notification, _ logStr: String = EMPTYSTRING) { 132 if let transition = notification.userInfo?[TRANSACTIONTRANSITION] as? TransactionTransition { 133 if transition.transactionId == transactionId { // is the transition for THIS transaction? 134 let newMajor = transition.newTxState.major 135 Task { // runs on MainActor 136 // flush the screen first, then reload 137 withAnimation { talerTX = TalerTransaction(dummyCurrency: DEMOCURRENCY); viewId = UUID() } 138 symLog.log("newState: \(newMajor), reloading transaction") 139 if newMajor != .none { // don't reload after delete 140 await loadTransaction() 141 } 142 } 143 } 144 } else { // Yikes - should never happen 145 // TODO: logger.warning("Can't get notification.userInfo as TransactionTransition") 146 symLog.log(notification.userInfo as Any) 147 } 148 } 149 150 func localizedState(_ txState: TransactionState) -> String { 151 if let minorState = txState.minor { 152 if developerMode { return minorState.localizedDbgState } 153 if talerTX.isPayment { 154 // return String(localized: "Payment", comment: "TxMajorState heading") 155 } 156 return minorState.localizedState ?? txState.major.localizedState 157 } 158 return txState.major.localizedState 159 } 160 161 @ViewBuilder 162 func dateAndStatus(_ common: TransactionCommon) -> some View { 163 let (dateString, date) = TalerDater.dateString(common.timestamp, minimalistic) 164 let a11yDate = TalerDater.accessibilityDate(date) ?? dateString 165 Text(dateString) 166 .talerFont(.body) 167 .accessibilityLabel(a11yDate) 168 .foregroundColor(WalletColors().secondary(colorScheme, colorSchemeContrast)) 169 .id(topID) 170 let state = localizedState(common.txState) 171 let statusT = Text(state) 172 .multilineTextAlignment(.trailing) 173 let imageT = Text(common.type.icon()) 174 .accessibilityHidden(true) 175 HStack(alignment: .center, spacing: HSPACING) { 176 imageT 177 Spacer(minLength: 0) 178 statusT 179 } 180 if developerMode { 181 if !jsonTransaction.isEmpty { 182 CopyButton(textToCopy: jsonTransaction, isCopied: $isCopied, title: "Copy JSON") 183 } 184 } 185 } 186 187 @ViewBuilder 188 func suspendResume(_ common: TransactionCommon) -> some View { 189 if talerTX.isSuspendable { 190 TransactionButton(transactionId: common.transactionId, 191 command: .suspend, 192 warning: nil, 193 didExecute: $ignoreThis, 194 action: model.suspendTransaction) 195 .listRowSeparator(.hidden) 196 } 197 if talerTX.isResumable { 198 TransactionButton(transactionId: common.transactionId, 199 command: .resume, 200 warning: nil, 201 didExecute: $ignoreThis, 202 action: model.resumeTransaction) 203 .listRowSeparator(.hidden) 204 } 205 } 206 207 @ViewBuilder 208 func abortFailDelete(_ common: TransactionCommon) -> some View { 209 if talerTX.isAbortable { 210 let warning = String(localized: "Are you sure you want to abort this transaction?") 211 TransactionButton(transactionId: common.transactionId, 212 command: .abort, 213 warning: warning, 214 didExecute: $ignoreThis, 215 action: model.abortTransaction) 216 } // Abort button 217 if talerTX.isFailable { 218 let warning = String(localized: "Are you sure you want to abandon this transaction?") 219 TransactionButton(transactionId: common.transactionId, 220 command: .fail, 221 warning: warning, 222 didExecute: $ignoreThis, 223 action: model.failTransaction) 224 } // Fail button 225 if talerTX.isDeleteable { 226 let warning = String(localized: "Are you sure you want to delete this transaction?") 227 TransactionButton(transactionId: common.transactionId, 228 command: .delete, 229 warning: warning, 230 didExecute: $didDelete, 231 action: model.deleteTransaction) 232 .onChange(of: didDelete) { wasDeleted in 233 if wasDeleted { 234 symLog.log("wasDeleted -> dismiss view") 235 dismiss(stack) 236 } 237 } 238 } // Delete button 239 } 240 241 var body: some View { 242 #if PRINT_CHANGES 243 let _ = Self._printChanges() 244 let _ = symLog.vlog() // just to get the # to compare it with .onAppear & onDisappear 245 #endif 246 let common = talerTX.common 247 // let scope = common.scopes.first // might be nil if scopes == [] 248 let locale = TalerDater.shared.locale 249 let isPaying = talerTX.isPayment && talerTX.isDialog 250 let navTitle2 = talerTX.isDone ? talerTX.localizedTypePast 251 : isPaying ? String(localized: "Confirm Payment", comment:"pay merchant navTitle") 252 : talerTX.localizedType 253 Group { 254 if common.type != .dummy && transactionId == common.transactionId { 255 let list = List { 256 if developerMode && withActions { suspendResume(common) } 257 if !isPaying { 258 dateAndStatus(common) 259 .listRowSeparator(.hidden) 260 .talerFont(.title) 261 } 262 TypeDetail(stack: stack.push(), 263 transaction: $talerTX, 264 payNow: $payNow, 265 selectedChoice: $selectedChoice, 266 scope: $scope, 267 effective: $effective, 268 hasDone: hasDone) 269 270 // TODO: Retry Countdown, Retry Now button 271 // if talerTX.isRetryable, let retryAction { 272 // TransactionButton(transactionId: common.transactionId, command: .retry, 273 // warning: nil, action: abortAction) 274 // } // Retry button 275 if withActions { abortFailDelete(common) } 276 }.id(viewId) // change viewId to enforce a draw update 277 .listStyle(myListStyle.style).anyView 278 .navigationBarBackButtonHidden(hasDone) 279 .interactiveDismissDisabled(hasDone) // can only use "Done" button to dismiss 280 .safeAreaInset(edge: .bottom) { 281 if isPaying, case .payment(let paymentTransaction) = talerTX { 282 let details = paymentTransaction.details 283 if let effective, let url, let terms = details.contractTerms { 284 let formatted = effective.formatted(currencyInfo) 285 PaySafeArea(symLog: symLog, 286 stack: stack.push(), 287 terms: terms, 288 amountString: formatted.0, 289 amountA11y: formatted.1, 290 payNow: $payNow) 291 .onChange(of: payNow) { payNow2 in 292 if payNow2 { 293 Task { 294 payNow = false 295 await payTransaction() 296 } 297 } 298 } 299 } else { 300 Button("Cancel") { dismissTop(stack.push()) } 301 .buttonStyle(TalerButtonStyle(type: .bordered)) 302 .padding(.horizontal) 303 } // Cancel 304 } else if let showDone { 305 Button("Done") { dismissTop(stack.push()) } 306 .buttonStyle(TalerButtonStyle(type: showDone)) 307 .padding(.horizontal) 308 } 309 } 310 .onNotification(.TransactionExpired) { notification in 311 // TODO: Alert user that this tx just expired 312 if checkDismiss(notification, "newTxState.major == expired => dismiss sheet") { 313 // TODO: logger.info("newTxState.major == expired => dismiss sheet") 314 } 315 } 316 .onNotification(.TransactionDone) { notification in 317 checkDismiss(notification, "newTxState.major == done => dismiss sheet") 318 } 319 .onNotification(.DismissSheet) { notification in 320 checkDismiss(notification, "exchangeWaitReserve or withdrawCoins => dismiss sheet") 321 } 322 .onNotification(.PendingReady) { notification in 323 checkReload(notification, "pending ready ==> reload for talerURI") 324 } 325 .onNotification(.TransactionStateTransition) { notification in 326 if !didDelete { 327 checkReload(notification, "some transition ==> reload") 328 } 329 } 330 .navigationTitle(navTitle ?? navTitle2) 331 332 if #available(iOS 17.0, *) { 333 list.toolbarTitleDisplayMode(.inlineLarge) 334 } else { 335 list 336 } 337 338 339 } else { 340 Color.clear 341 .frame(maxWidth: .infinity, maxHeight: .infinity) 342 .task { 343 symLog.log("task - load transaction") 344 await loadTransaction() 345 } 346 } // else 347 } // Group 348 .onChange(of: scope) { newVal in 349 if let newVal { 350 currencyInfo = controller.info(for: newVal) ?? CurrencyInfo.zero(UNKNOWN) 351 } 352 } 353 .onAppear { 354 symLog.log("onAppear") 355 DebugViewC.shared.setViewID(VIEW_TRANSACTIONSUMMARY, stack: stack.push()) 356 } 357 .onDisappear { 358 symLog.log("onDisappear") 359 } 360 } 361 // MARK: - 362 struct MerchantHeader: View { 363 let terms: MerchantContractTerms? 364 365 var body: some View { 366 if let terms { 367 Section { 368 Text(terms.summary) 369 .talerFont(.title3) 370 } header: { 371 HStack { 372 Spacer() 373 VStack(alignment: .center) { 374 #if TALER_NIGHTLY 375 let imageName = if #available(iOS 17.0, *) { MERCHANT17 } else { MERCHANT14 } 376 Image(systemName: imageName) 377 .resizable() 378 .frame(width: 44, height: 44) 379 #endif 380 let merchant = terms.merchant.name 381 Text(merchant) 382 .talerFont(.title3) 383 }.foregroundStyle(Color(.primary)) 384 Spacer() 385 } 386 } 387 } 388 } 389 } 390 // MARK: - 391 struct KYCbutton: View { 392 let kycUrl: String? 393 394 var body: some View { 395 if let kycUrl { 396 if let destination = URL(string: kycUrl) { 397 LinkButton(destination: destination, 398 hintTitle: String(localized: "You need to pass a legitimization procedure.", comment: "KYC"), 399 buttonTitle: String(localized: "Open legitimization website", comment: "KYC"), 400 a11yHint: String(localized: "Will go to legitimization website to permit this withdrawal.", comment: "a11y"), 401 badge: NEEDS_KYC) 402 } 403 } 404 } 405 } 406 // MARK: - 407 struct PendingWithdrawalDetails: View { 408 let stack: CallStack 409 @Binding var transaction: TalerTransaction 410 let details: WithdrawalTransactionDetails 411 412 var body: some View { 413 let common = transaction.common 414 if transaction.isPendingKYC { 415 if let kycUrl = common.kycUrl { 416 KYCbutton(kycUrl: common.kycUrl) 417 } else { 418 Text("Legitimization procedure required", comment: "KYC") 419 } 420 } 421 let withdrawalDetails = details.withdrawalDetails 422 switch withdrawalDetails.type { 423 case .manual: // "Make a wire transfer of \(amount) to" 424 ManualDetailsV(stack: stack.push(), common: common, details: withdrawalDetails) 425 426 case .bankIntegrated: // "Authorize now" (with bank) 427 if !transaction.isPendingKYC { // cannot authorize if KYC is needed first 428 let confirmed = withdrawalDetails.confirmed ?? false 429 if !confirmed { 430 if let confirmationUrl = withdrawalDetails.bankConfirmationUrl { 431 if let destination = URL(string: confirmationUrl) { 432 LinkButton(destination: destination, 433 hintTitle: String(localized: "The bank is waiting for your authorization."), 434 buttonTitle: String(localized: "Authorize now"), 435 a11yHint: String(localized: "Will go to bank website to authorize this withdrawal.", comment: "a11y"), 436 badge: CONFIRM_BANK) 437 } } } } 438 @unknown default: 439 ErrorView(stack.push(), 440 title: "Unknown withdrawal type", // should not happen, so no L10N 441 message: withdrawalDetails.type.rawValue, 442 copyable: true) { 443 dismissTop(stack.push()) 444 } 445 } // switch 446 } 447 } 448 // MARK: - 449 struct TypeDetail: View { 450 let stack: CallStack 451 @Binding var transaction: TalerTransaction 452 @Binding var payNow: Bool 453 @Binding var selectedChoice: Int 454 @Binding var scope: ScopeInfo? 455 @Binding var effective: Amount? 456 let hasDone: Bool 457 @Environment(\.colorScheme) private var colorScheme 458 @Environment(\.colorSchemeContrast) private var colorSchemeContrast 459 @AppStorage("minimalistic") var minimalistic: Bool = false 460 @State private var rotationEnabled = true 461 @State private var ignoreAccept: Bool = false // accept could be set by OIM to trigger accept 462 @State private var isCopied: Bool = false 463 464 func refreshFee(input: Amount, output: Amount) -> Amount? { 465 do { 466 let fee = try input - output 467 return fee 468 } catch { 469 470 } 471 return nil 472 } 473 474 func abortedHint(_ delay: Duration?) -> UInt? { 475 if let delay { 476 if let microseconds = try? delay.microseconds() { 477 let days = microseconds / (24 * 3600 * 1000 * 1000) 478 if days > 0 { 479 return UInt(days) 480 } 481 } 482 return 0 483 } 484 return nil 485 } 486 487 struct PaymentTransactionView: View { 488 let stack: CallStack 489 let common: TransactionCommon 490 let paymentTransaction: PaymentTransaction 491 @Binding var scope: ScopeInfo? 492 @Binding var effective: Amount? 493 @Binding var payNow: Bool 494 @Binding var selectedChoice: Int 495 496 @EnvironmentObject private var model: WalletModel 497 @State var choicesForPayment: GetChoicesForPaymentResult? = nil 498 @State var isLoadingChoices: Bool = false 499 500 @MainActor 501 func choiceTriple() -> [ChoiceTriple]? { 502 if let choicesForPayment { 503 if let ctChoices = choicesForPayment.contractTerms.choices { 504 let combined = Array(zip(choicesForPayment.choices, ctChoices, ctChoices.indices)) 505 return combined 506 } 507 } 508 return nil 509 } 510 511 private func getChoicesForPayment() async { 512 if !isLoadingChoices { 513 isLoadingChoices = true 514 let txId = common.transactionId 515 if let choiceResponse = try? await model.getChoicesForPayment(txId) { 516 choicesForPayment = choiceResponse 517 } 518 } else { 519 print("getChoicesForPayment already in progress") 520 } 521 } 522 523 func summary(_ info: OrderShortInfo?) -> String? { 524 if let i18nDict = info?.summary_i18n { 525 if !i18nDict.isEmpty { 526 for code in Locale.preferredLanguageCodes { 527 if let i18n = i18nDict[code] { 528 return i18n 529 } 530 } 531 } 532 } 533 if let summary = info?.summary { 534 return summary 535 } 536 return String(localized: "No summary", comment: "OrderShortInfo.summary") 537 } 538 539 var body: some View { 540 Group { 541 let details = paymentTransaction.details 542 if common.isDialog { // show payment confirmation dialog 543 MerchantHeader(terms: details.contractTerms) 544 545 if let choices = choiceTriple() { 546 let hasAutomatic = choicesForPayment?.automaticExecution ?? false 547 let automaticIndex = hasAutomatic ? choicesForPayment?.automaticExecutableIndex : nil 548 ChoicesView(stack: stack.push(), 549 choiceTriple: choices, 550 automaticIndex: automaticIndex, 551 selectedChoice: $selectedChoice) 552 .onChange(of: selectedChoice) { newValue in 553 let newChoice = choices[newValue] 554 effective = newChoice.0.amountEffective 555 scope = newChoice.0.scopeInfo 556 } 557 .task { 558 let firstChoice = choices[selectedChoice] 559 effective = firstChoice.0.amountEffective 560 scope = firstChoice.0.scopeInfo 561 if let automaticIndex { 562 // Pay Automatically 563 selectedChoice = automaticIndex 564 payNow = true 565 } 566 } 567 568 let choice = choices[selectedChoice] 569 let selectionDetail: ChoiceSelectionDetail = choice.0 570 let contractChoice: ContractChoice = choice.1 571 572 // if selectionDetail.status == .paymentPossible { 573 PaymentView2(stack: stack.push(), // TODO: details.info.merchant.name 574 paid: false, 575 raw: selectionDetail.amountRaw, 576 effective: effective, 577 firstScope: scope, 578 baseURL: nil, 579 summary: summary(details.info), 580 products: details.info?.products ?? [], 581 balanceDetails: selectionDetail.balanceDetails) 582 // } 583 } 584 } else { // show finished payment 585 TransactionPayDetailV(paymentTx: paymentTransaction) // TODO: details.info.merchant.name 586 ThreeAmountsSheet(stack: stack.push(), 587 scope: scope, 588 common: common, 589 topAbbrev: String(localized: "Price:", comment: "mini"), 590 topTitle: String(localized: "Price (net):"), 591 baseURL: nil, // TODO: baseURL 592 noFees: nil, // TODO: noFees 593 feeIsNegative: false, 594 large: true, 595 summary: details.info?.summary ?? EMPTYSTRING) 596 } // show finished payment 597 } 598 .task (id: common.txState.hashValue) { 599 if common.isDialog { 600 if choicesForPayment == nil { 601 await getChoicesForPayment() 602 } 603 } 604 } 605 } // body 606 } // PaymentTransactionView 607 608 var body: some View { 609 let common = transaction.common 610 let pending = transaction.isPending 611 let isDialog = transaction.isDialog 612 Group { 613 switch transaction { 614 case .dummy(_): Group { 615 let title = EMPTYSTRING 616 Text(title) 617 .talerFont(.body) 618 RotatingTaler(size: 100, progress: true, once: false, rotationEnabled: $rotationEnabled) 619 .frame(maxWidth: .infinity, alignment: .center) 620 // has its own accessibilityLabel 621 } 622 case .withdrawal(let withdrawalTransaction): Group { 623 let details = withdrawalTransaction.details 624 if common.isAborted && details.withdrawalDetails.type == .manual { 625 if let days = abortedHint(details.withdrawalDetails.reserveClosingDelay) { 626 let wireBack = days > 0 ? String(localized: "If you have already sent money to the payment service, it will wire it back in \(days) days.") 627 : String(localized: "If you have already sent money to the payment service, it will wire it back in a few days.") 628 Text("The withdrawal was aborted.\n\n\(wireBack)") 629 .talerFont(.callout) 630 } 631 } 632 if pending { 633 PendingWithdrawalDetails(stack: stack.push(), 634 transaction: $transaction, 635 details: details) 636 } // ManualDetails or Confirm now (with bank) 637 ThreeAmountsSheet(stack: stack.push(), 638 scope: scope, 639 common: common, 640 topAbbrev: String(localized: "Chosen:", comment: "mini"), 641 topTitle: String(localized: "Chosen amount to withdraw:"), 642 baseURL: details.exchangeBaseUrl, 643 noFees: nil, // TODO: noFees 644 feeIsNegative: true, 645 large: false, 646 summary: nil) 647 } 648 case .deposit(let depositTransaction): Group { 649 if transaction.common.isPendingKYCauth { 650 KYCauth(stack: stack.push(), common: common) 651 } else if transaction.isPendingKYC { 652 KYCbutton(kycUrl: common.kycUrl) 653 } 654 ThreeAmountsSheet(stack: stack.push(), 655 scope: scope, 656 common: common, 657 topAbbrev: String(localized: "Deposit:", comment: "mini"), 658 topTitle: String(localized: "Amount to deposit:"), 659 baseURL: nil, // TODO: baseURL 660 noFees: nil, // TODO: noFees 661 feeIsNegative: false, 662 large: true, 663 summary: nil) 664 } 665 666 case .payment(let paymentTransaction): 667 PaymentTransactionView(stack: stack.push(), 668 common: common, 669 paymentTransaction: paymentTransaction, 670 scope: $scope, 671 effective: $effective, 672 payNow: $payNow, 673 selectedChoice: $selectedChoice) 674 675 case .refund(let refundTransaction): Group { 676 let details = refundTransaction.details // TODO: more details, details.info?.merchant.name 677 ThreeAmountsSheet(stack: stack.push(), 678 scope: scope, 679 common: common, 680 topAbbrev: String(localized: "Refunded:", comment: "mini"), 681 topTitle: String(localized: "Refunded amount:"), 682 baseURL: nil, // TODO: baseURL 683 noFees: nil, // TODO: noFees 684 feeIsNegative: true, 685 large: true, 686 summary: details.info?.summary) 687 } 688 case .refresh(let refreshTransaction): Group { 689 let labelColor = WalletColors().labelColor 690 let errorColor = WalletColors().errorColor 691 let details = refreshTransaction.details 692 Section { 693 Text(details.refreshReason.localizedRefreshReason) 694 .talerFont(.title) 695 let input = details.refreshInputAmount 696 AmountRowV(stack: stack.push(), 697 title: minimalistic ? "Refreshed:" : "Refreshed amount:", 698 amount: input, 699 scope: scope, 700 isNegative: nil, 701 color: labelColor, 702 large: true) 703 if let fee = refreshFee(input: input, output: details.refreshOutputAmount) { 704 AmountRowV(stack: stack.push(), 705 title: minimalistic ? "Fee:" : "Refreshed fee:", 706 amount: fee, 707 scope: scope, 708 isNegative: fee.isZero ? nil : true, 709 color: labelColor, 710 large: true) 711 } 712 if let error = details.error { 713 HStack { 714 VStack(alignment: .leading) { 715 Text(error.hint) 716 .talerFont(.headline) 717 .foregroundColor(errorColor) 718 .listRowSeparator(.hidden) 719 if let stack = error.stack { 720 Text(stack) 721 .talerFont(.body) 722 .foregroundColor(errorColor) 723 .listRowSeparator(.hidden) 724 } 725 } 726 let stackStr = error.stack ?? EMPTYSTRING 727 let errorStr = error.hint + "\n" + stackStr 728 CopyButton(textToCopy: errorStr, isCopied: $isCopied, vertical: true) 729 .accessibilityLabel(Text("Copy the error", comment: "a11y")) 730 .disabled(false) 731 } 732 } 733 } 734 } 735 736 case .peer2peer(let p2pTransaction): Group { 737 let details = p2pTransaction.details 738 if transaction.isPendingKYC { 739 KYCbutton(kycUrl: common.kycUrl) 740 } 741 if !transaction.isDone { 742 ExpiresView(expiration: details.info.expiration) 743 } 744 if transaction.isRcvCoins && common.isDialog { 745 PeerPushCreditView(stack: stack.push(), 746 raw: common.amountRaw, 747 effective: common.amountEffective, 748 scope: scope, 749 summary: details.info.summary) 750 PeerPushCreditAccept(stack: stack.push(), url: nil, 751 transactionId: common.transactionId, 752 accept: $ignoreAccept) 753 } else if transaction.isPayInvoice && common.isDialog { 754 PeerPullDebitView(stack: stack.push(), 755 raw: common.amountRaw, 756 effective: common.amountEffective, 757 scope: scope, 758 summary: details.info.summary) 759 PeerPullDebitConfirm(stack: stack.push(), url: nil, 760 transactionId: common.transactionId) 761 } else { 762 // 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 763 // TODO: details.info.summary 764 if pending { 765 if transaction.isPendingReady { 766 QRCodeDetails(transaction: transaction) 767 if hasDone { 768 Text("QR code and link can also be scanned or copied / shared from Transactions later.") 769 .multilineTextAlignment(.leading) 770 .talerFont(.subheadline) 771 // .padding(.top) 772 } 773 } else { 774 Text("This transaction is not yet ready...") 775 .multilineTextAlignment(.leading) 776 .talerFont(.subheadline) 777 } 778 } 779 let colon = ":" 780 let localizedType = transaction.isDone ? transaction.localizedTypePast 781 : transaction.localizedType 782 ThreeAmountsSheet(stack: stack.push(), 783 scope: scope, 784 common: common, 785 topAbbrev: localizedType + colon, 786 topTitle: localizedType + colon, 787 baseURL: details.exchangeBaseUrl, 788 noFees: nil, // TODO: noFees 789 feeIsNegative: true, 790 large: false, 791 summary: details.info.summary) 792 } // else 793 } // p2p 794 795 case .recoup(let recoupTransaction): Group { 796 let details = recoupTransaction.details // TODO: details.recoupReason 797 ThreeAmountsSheet(stack: stack.push(), 798 scope: scope, 799 common: common, 800 topAbbrev: String(localized: "Recoup:", comment: "mini"), 801 topTitle: String(localized: "Recoup:"), 802 baseURL: nil, // TODO: baseURL, noFees 803 noFees: nil, 804 feeIsNegative: nil, 805 large: true, 806 summary: details.recoupReason) 807 } 808 case .denomLoss(let denomLossTransaction): Group { 809 let details = denomLossTransaction.details // TODO: more details, details.lossEventType.rawValue 810 ThreeAmountsSheet(stack: stack.push(), 811 scope: scope, 812 common: common, 813 topAbbrev: String(localized: "Lost:", comment: "mini"), 814 topTitle: String(localized: "Money lost:"), 815 baseURL: details.exchangeBaseUrl, 816 noFees: nil, // TODO: noFees 817 feeIsNegative: nil, 818 large: true, 819 summary: details.lossEventType.rawValue) 820 } 821 } // switch 822 } // Group 823 } 824 } 825 // MARK: - 826 struct QRCodeDetails: View { 827 var transaction : TalerTransaction 828 var body: some View { 829 let details = transaction.detailsToShow() 830 let keys = details.keys 831 if keys.contains(TALERURI) { 832 if let talerURI = details[TALERURI] { 833 if talerURI.count > 10 { 834 QRCodeDetailView(talerURI: talerURI, 835 talerCopyShare: talerURI, 836 incoming: transaction.isP2pIncoming, 837 amount: transaction.common.amountRaw, 838 scope: transaction.common.scopes.first) 839 // scopes shouldn't (- but might) be nil! 840 } 841 } 842 } else if keys.contains(EXCHANGEBASEURL) { 843 if let baseURL = details[EXCHANGEBASEURL] { 844 Text("from \(baseURL.trimURL)", comment: "baseURL") 845 .talerFont(.title2) 846 .padding(.bottom) 847 } 848 } 849 } 850 } 851 } // TransactionSummaryV 852 // MARK: - 853 #if DEBUG 854 //struct TransactionSummary_Previews: PreviewProvider { 855 // static func deleteTransactionDummy(transactionId: String) async throws {} 856 // static func doneActionDummy() {} 857 // static var withdrawal = TalerTransaction(incoming: true, 858 // pending: true, 859 // id: "some withdrawal ID", 860 // time: Timestamp(from: 1_666_000_000_000)) 861 // static var payment = TalerTransaction(incoming: false, 862 // pending: false, 863 // id: "some payment ID", 864 // time: Timestamp(from: 1_666_666_000_000)) 865 // static func reloadActionDummy(transactionId: String) async -> TalerTransaction { return withdrawal } 866 // static var previews: some View { 867 // Group { 868 // TransactionSummaryV(transaction: withdrawal, reloadAction: reloadActionDummy, doneAction: doneActionDummy) 869 // TransactionSummaryV(transaction: payment, reloadAction: reloadActionDummy) 870 // } 871 // } 872 //} 873 #endif