taler-ios

iOS apps for GNU Taler (wallet)
Log | Files | Refs | README | LICENSE

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