taler-ios

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

TransactionSummaryV.swift (39438B)


      1 /*
      2  * This file is part of GNU Taler, ©2022-25 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 TransactionSummaryV: View {
     29     private let symLog = SymLogV(0)
     30     let stack: CallStack
     31 //    let scope: ScopeInfo?
     32     let transactionId: String
     33     @Binding var talerTX: TalerTransaction
     34     let navTitle: String?
     35     let hasDone: Bool
     36     let abortAction: ((_ transactionId: String, _ viewHandles: Bool) async throws -> Void)?
     37     let deleteAction: ((_ transactionId: String, _ viewHandles: Bool) async throws -> Void)?
     38     let failAction: ((_ transactionId: String, _ viewHandles: Bool) async throws -> Void)?
     39     let suspendAction: ((_ transactionId: String, _ viewHandles: Bool) async throws -> Void)?
     40     let resumeAction: ((_ transactionId: String, _ viewHandles: Bool) async throws -> Void)?
     41 
     42     @EnvironmentObject private var controller: Controller
     43     @EnvironmentObject private var model: WalletModel
     44     @Environment(\.colorScheme) private var colorScheme
     45     @Environment(\.colorSchemeContrast) private var colorSchemeContrast
     46     @Environment(\.dismiss) var dismiss     // call dismiss() to pop back
     47     @AppStorage("minimalistic") var minimalistic: Bool = false
     48     @AppStorage("myListStyle") var myListStyle: MyListStyle = .automatic
     49 #if DEBUG
     50     @AppStorage("developerMode") var developerMode: Bool = true
     51 #else
     52     @AppStorage("developerMode") var developerMode: Bool = false
     53 #endif
     54 
     55     @State private var ignoreThis: Bool = false
     56     @State private var didDelete: Bool = false
     57     @State var jsonTransaction: String = EMPTYSTRING
     58     @State var viewId = UUID()
     59     @Namespace var topID
     60 
     61     func loadTransaction() async {
     62         if let reloadedTransaction = try? await model.getTransactionById(transactionId,
     63                                                     includeContractTerms: true, viewHandles: false) {
     64             symLog.log("reloaded \(reloadedTransaction.localizedType): \(reloadedTransaction.common.txState.major)")
     65             withAnimation { talerTX = reloadedTransaction; viewId = UUID() }      // redraw
     66             if developerMode {
     67                 if let json = try? await model.jsonTransactionById(transactionId,
     68                                               includeContractTerms: true, viewHandles: false) {
     69                     jsonTransaction = json
     70                 } else {
     71                     jsonTransaction = EMPTYSTRING
     72                 }
     73             }
     74         } else {
     75             withAnimation{ talerTX = TalerTransaction(dummyCurrency: DEMOCURRENCY); viewId = UUID() }
     76             jsonTransaction = EMPTYSTRING
     77         }
     78     }
     79 
     80     @MainActor
     81     @discardableResult
     82     func checkDismiss(_ notification: Notification, _ logStr: String = EMPTYSTRING) -> Bool {
     83         if hasDone {
     84             if let transition = notification.userInfo?[TRANSACTIONTRANSITION] as? TransactionTransition {
     85                 if transition.transactionId == talerTX.common.transactionId {       // is the transition for THIS transaction?
     86                     symLog.log(logStr)
     87                     dismissTop(stack.push())        // if this view is in a sheet then dissmiss the sheet
     88                     return true
     89                 }
     90             }
     91         } else { // no sheet but the details view -> reload
     92             checkReload(notification, logStr)
     93         }
     94         return false
     95     }
     96 
     97     @MainActor
     98     private func dismiss(_ stack: CallStack) {
     99         if hasDone {        // if this view is in a sheet then dissmiss the whole sheet
    100             dismissTop(stack.push())
    101         } else {            // on a NavigationStack just pop
    102             dismiss()
    103         }
    104     }
    105 
    106     func checkReload(_ notification: Notification, _ logStr: String = EMPTYSTRING) {
    107         if let transition = notification.userInfo?[TRANSACTIONTRANSITION] as? TransactionTransition {
    108             if transition.transactionId == transactionId {       // is the transition for THIS transaction?
    109                 let newMajor = transition.newTxState.major
    110                 Task { // runs on MainActor
    111                        // flush the screen first, then reload
    112                     withAnimation { talerTX = TalerTransaction(dummyCurrency: DEMOCURRENCY); viewId = UUID() }
    113                     symLog.log("newState: \(newMajor), reloading transaction")
    114                     if newMajor != .none {              // don't reload after delete
    115                         await loadTransaction()
    116                     }
    117                 }
    118             }
    119         } else { // Yikes - should never happen
    120 // TODO:      logger.warning("Can't get notification.userInfo as TransactionTransition")
    121             symLog.log(notification.userInfo as Any)
    122         }
    123     }
    124 
    125     func localizedState() -> String {
    126         let txState = talerTX.common.txState
    127         if talerTX.isPending {
    128             if let minorState = txState.minor {
    129                 return developerMode ? minorState.localizedDbgState
    130                                      : minorState.localizedState ?? txState.major.localizedState
    131             }
    132         }
    133         return txState.major.localizedState
    134     }
    135 
    136     var body: some View {
    137 #if PRINT_CHANGES
    138         let _ = Self._printChanges()
    139         let _ = symLog.vlog()       // just to get the # to compare it with .onAppear & onDisappear
    140 #endif
    141         let common = talerTX.common
    142         if common.type != .dummy && transactionId == common.transactionId {
    143             let scope = common.scopes.first                                     // might be nil if scopes == []
    144 //            let pending = transaction.isPending
    145             let locale = TalerDater.shared.locale
    146             let (dateString, date) = TalerDater.dateString(common.timestamp, minimalistic)
    147             let a11yDate = TalerDater.accessibilityDate(date) ?? dateString
    148             let navTitle2 = talerTX.isDone ? talerTX.localizedTypePast
    149                                            : talerTX.localizedType
    150             List {
    151                 if developerMode {
    152                     if talerTX.isSuspendable { if let suspendAction {
    153                         TransactionButton(transactionId: common.transactionId,
    154                                                 command: .suspend,
    155                                                 warning: nil,
    156                                              didExecute: $ignoreThis,
    157                                                  action: suspendAction)
    158                         .listRowSeparator(.hidden)
    159                     } }
    160                     if talerTX.isResumable { if let resumeAction {
    161                         TransactionButton(transactionId: common.transactionId,
    162                                                 command: .resume,
    163                                                 warning: nil,
    164                                              didExecute: $ignoreThis,
    165                                                  action: resumeAction)
    166                         .listRowSeparator(.hidden)
    167                     } }
    168                 } // Suspend + Resume buttons
    169                 Group {
    170                     Text(dateString)
    171                         .talerFont(.body)
    172                         .accessibilityLabel(a11yDate)
    173                         .foregroundColor(WalletColors().secondary(colorScheme, colorSchemeContrast))
    174                         .id(topID)
    175                     let state = localizedState()
    176                     let statusT = Text(state)
    177                                     .multilineTextAlignment(.trailing)
    178                     let imageT = Text(common.type.icon())
    179                                     .accessibilityHidden(true)
    180                     HStack(alignment: .center, spacing: HSPACING) {
    181                         imageT
    182                         Spacer(minLength: 0)
    183                         statusT
    184                     }
    185                     if developerMode {
    186                         if !jsonTransaction.isEmpty {
    187                             CopyButton(textToCopy: jsonTransaction, title: "Copy JSON")
    188                         }
    189                     }
    190                 }   .listRowSeparator(.hidden)
    191                     .talerFont(.title)
    192                     .onAppear {     // doesn't work - view still jumps
    193 //                        scrollView.scrollTo(topID)
    194 //                        withAnimation { scrollView.scrollTo(topID) }
    195                     }
    196 
    197                 TypeDetail(stack: stack.push(),
    198                            scope: scope,
    199                      transaction: $talerTX,
    200                          hasDone: hasDone)
    201 
    202                 // TODO: Retry Countdown, Retry Now button
    203 //                if talerTX.isRetryable, let retryAction {
    204 //                    TransactionButton(transactionId: common.transactionId, command: .retry,
    205 //                                      warning: nil, action: abortAction)
    206 //                } // Retry button
    207                 if talerTX.isAbortable, let abortAction {
    208                     TransactionButton(transactionId: common.transactionId,
    209                                             command: .abort,
    210                                             warning: String(localized: "Are you sure you want to abort this transaction?"),
    211                                          didExecute: $ignoreThis,
    212                                              action: abortAction)
    213                 } // Abort button
    214                 if talerTX.isFailable, let failAction {
    215                     TransactionButton(transactionId: common.transactionId,
    216                                             command: .fail,
    217                                             warning: String(localized: "Are you sure you want to abandon this transaction?"),
    218                                          didExecute: $ignoreThis,
    219                                              action: failAction)
    220                 } // Fail button
    221                 if talerTX.isDeleteable, let deleteAction {
    222                     TransactionButton(transactionId: common.transactionId,
    223                                             command: .delete,
    224                                             warning: String(localized: "Are you sure you want to delete this transaction?"),
    225                                          didExecute: $didDelete,
    226                                              action: deleteAction)
    227                     .onChange(of: didDelete) { wasDeleted in
    228                         if wasDeleted {
    229                             symLog.log("wasDeleted -> dismiss view")
    230                             dismiss(stack)
    231                         }
    232                     }
    233                 } // Delete button
    234             }.id(viewId)    // change viewId to enforce a draw update
    235             .listStyle(myListStyle.style).anyView
    236             .onNotification(.TransactionExpired) { notification in
    237                 // TODO: Alert user that this tx just expired
    238                 if checkDismiss(notification, "newTxState.major == expired  => dismiss sheet") {
    239         // TODO:                  logger.info("newTxState.major == expired  => dismiss sheet")
    240                 }
    241             }
    242             .onNotification(.TransactionDone) { notification in
    243                 checkDismiss(notification, "newTxState.major == done  => dismiss sheet")
    244             }
    245             .onNotification(.DismissSheet) { notification in
    246                 checkDismiss(notification, "exchangeWaitReserve or withdrawCoins  => dismiss sheet")
    247             }
    248             .onNotification(.PendingReady) { notification in
    249                 checkReload(notification, "pending ready ==> reload for talerURI")
    250             }
    251             .onNotification(.TransactionStateTransition) { notification in
    252                 if !didDelete {
    253                     checkReload(notification, "some transition ==> reload")
    254                 }
    255             }
    256             .navigationTitle(navTitle ?? navTitle2)
    257             .onAppear {
    258                 symLog.log("onAppear")
    259                 DebugViewC.shared.setViewID(VIEW_TRANSACTIONSUMMARY, stack: stack.push())
    260             }
    261             .onDisappear {
    262                 symLog.log("onDisappear")
    263             }
    264         } else {
    265             Color.clear
    266                 .frame(maxWidth: .infinity, maxHeight: .infinity)
    267                 .task {
    268                     symLog.log("task - load transaction")
    269                     await loadTransaction()
    270                 }
    271         }
    272     }
    273     // MARK: -
    274     struct KYCbutton: View {
    275         let kycUrl: String?
    276 
    277         var body: some View {
    278             if let kycUrl {
    279                 if let destination = URL(string: kycUrl) {
    280                     LinkButton(destination: destination,
    281                                hintTitle: String(localized: "You need to pass a legitimization procedure.", comment: "KYC"),
    282                                buttonTitle: String(localized: "Open legitimization website", comment: "KYC"),
    283                                   a11yHint: String(localized: "Will go to legitimization website to permit this withdrawal.", comment: "a11y"),
    284                                      badge: NEEDS_KYC)
    285                 }
    286             }
    287         }
    288     }
    289     // MARK: -
    290     struct KYCauth: View {
    291         let stack: CallStack
    292         let common: TransactionCommon
    293 
    294         @AppStorage("minimalistic") var minimalistic: Bool = false
    295         @State private var accountID = 0
    296         @State private var listID = UUID()
    297 
    298         func redraw(_ newAccount: Int) -> Void {
    299             if newAccount != accountID {
    300                 accountID = newAccount
    301                 withAnimation { listID = UUID() }
    302             }
    303         }
    304 
    305         func validDetails(_ paytoUris: [String]) -> [ExchangeAccountDetails] {
    306             var details: [ExchangeAccountDetails] = []
    307             for paytoUri in paytoUris {
    308                 let payTo = PayTo(paytoUri)
    309                 let amount = common.kycAuthTransferInfo?.amount
    310                 let detail = ExchangeAccountDetails(status: "ok",
    311                                                   paytoUri: paytoUri,
    312                                             transferAmount: amount,
    313                                                      scope: common.scopes[0])
    314                 details.append(detail)
    315             }
    316             return details
    317         }
    318 
    319         var body: some View {
    320             if let info = common.kycAuthTransferInfo {
    321                 let debitPayTo = PayTo(info.debitPaytoUri)
    322                 let amount = info.amount
    323                 let amountStr = amount.formatted(specs: nil,
    324                                             isNegative: false,
    325                                                  scope: common.scopes[0])
    326                 let amountValue = amount.valueStr
    327                 let creditPaytoUris = info.creditPaytoUris
    328                 let validDetails = validDetails(creditPaytoUris)
    329                 if validDetails.count > 0 {
    330                     let countPaytos = creditPaytoUris.count
    331                     let account = validDetails[accountID]
    332 
    333                     Text("You need to prove having control over the bank account for the deposit.")
    334                         .bold()
    335                         .fixedSize(horizontal: false, vertical: true)       // wrap in scrollview
    336                         .multilineTextAlignment(.leading)                   // otherwise
    337                         .listRowSeparator(.hidden)
    338 
    339                     if countPaytos > 1 {
    340                         if countPaytos > 3 { // too many for SegmentControl
    341                             AccountPicker(title: String(localized: "Bank"),
    342                                           value: $accountID,
    343                                  accountDetails: validDetails,
    344                                          action: redraw)
    345                             .listRowSeparator(.hidden)
    346                             .pickerStyle(.menu)
    347                         } else {
    348                             SegmentControl(value: $accountID,
    349                                   accountDetails: validDetails,
    350                                           action: redraw)
    351                                 .listRowSeparator(.hidden)
    352                         }
    353                     } else if let creditPaytoUri = creditPaytoUris.first {
    354                         if let bankName = account.bankLabel {
    355                             Text(bankName + ":   " + amountStr.0)
    356                                 .accessibilityLabel(bankName + ":   " + amountStr.1)
    357 //                      } else {
    358 //                          Text(amountStr)
    359                         }
    360                     }
    361                     let payto = PayTo(account.paytoUri)
    362                     if let receiverStr = payto.receiver {
    363                         let wireDetails = ManualDetailsWireV(stack: stack.push(),
    364                                                         reservePub: info.accountPub,
    365                                                        receiverStr: receiverStr,
    366                                                        receiverZip: payto.postalCode,
    367                                                       receiverTown: payto.town,
    368                                                               iban: payto.iban,
    369                                                             cyclos: payto.cyclos ?? EMPTYSTRING,
    370                                                             xTaler: payto.xTaler ?? EMPTYSTRING,
    371                                                        amountValue: amountValue,
    372                                                          amountStr: amountStr,
    373                                                          obtainStr: nil,        // only for withdrawal
    374                                                          debitIBAN: debitPayTo.iban,
    375                                                            account: account)
    376                         NavigationLink(destination: wireDetails) {
    377                             Text(minimalistic ? "Instructions"
    378                                               : "Wire transfer instructions")
    379                             .talerFont(.title3)
    380                         }
    381                     }
    382                 }
    383             }
    384         } // body
    385     }
    386     // MARK: -
    387     struct PendingWithdrawalDetails: View {
    388         let stack: CallStack
    389         @Binding var transaction: TalerTransaction
    390         let details: WithdrawalTransactionDetails
    391 
    392         var body: some View {
    393             let common = transaction.common
    394             if transaction.isPendingKYC {
    395                 if let kycUrl = common.kycUrl {
    396                     KYCbutton(kycUrl: common.kycUrl)
    397                 } else {
    398                     Text("Legitimization procedure required", comment: "KYC")
    399                 }
    400             }
    401             let withdrawalDetails = details.withdrawalDetails
    402             switch withdrawalDetails.type {
    403                 case .manual:               // "Make a wire transfer of \(amount) to"
    404                     ManualDetailsV(stack: stack.push(), common: common, details: withdrawalDetails)
    405 
    406                 case .bankIntegrated:       // "Authorize now" (with bank)
    407                     if !transaction.isPendingKYC {              // cannot authorize if KYC is needed first
    408                         let confirmed = withdrawalDetails.confirmed ?? false
    409                         if !confirmed {
    410                             if let confirmationUrl = withdrawalDetails.bankConfirmationUrl {
    411                                 if let destination = URL(string: confirmationUrl) {
    412                                     LinkButton(destination: destination,
    413                                                  hintTitle: String(localized: "The bank is waiting for your authorization."),
    414                                                buttonTitle: String(localized: "Authorize now"),
    415                                                   a11yHint: String(localized: "Will go to bank website to authorize this withdrawal.", comment: "a11y"),
    416                                                      badge: CONFIRM_BANK)
    417                     }   }   }   }
    418                 @unknown default:
    419                     ErrorView(stack.push(),
    420                               title: "Unknown withdrawal type",        // should not happen, so no L10N
    421                             message: withdrawalDetails.type.rawValue,
    422                            copyable: true) {
    423                         dismissTop(stack.push())
    424                     }
    425             } // switch
    426         }
    427     }
    428     // MARK: -
    429     struct TypeDetail: View {
    430         let stack: CallStack
    431         let scope: ScopeInfo?
    432         @Binding var transaction: TalerTransaction
    433         let hasDone: Bool
    434         @Environment(\.colorScheme) private var colorScheme
    435         @Environment(\.colorSchemeContrast) private var colorSchemeContrast
    436         @AppStorage("minimalistic") var minimalistic: Bool = false
    437         @State private var rotationEnabled = true
    438         @State private var ignoreAccept: Bool = false         // accept could be set by OIM to trigger accept
    439 
    440         func refreshFee(input: Amount, output: Amount) -> Amount? {
    441             do {
    442                 let fee = try input - output
    443                 return fee
    444             } catch {
    445                 
    446             }
    447             return nil
    448         }
    449 
    450         func abortedHint(_ delay: RelativeTime?) -> String? {
    451             if let delay {
    452                 if let microseconds = try? delay.microseconds() {
    453                     let days = microseconds / (24 * 3600 * 1000 * 1000)
    454                     if days > 0 {
    455                         return String(days)
    456                     }
    457                 }
    458                 return "a few"
    459             }
    460             return nil
    461         }
    462 
    463         var body: some View {
    464             let common = transaction.common
    465             let pending = transaction.isPending
    466             let dialog = transaction.isDialog
    467             Group {
    468                 switch transaction {
    469                     case .dummy(_):
    470                         let title = EMPTYSTRING
    471                         Text(title)
    472                             .talerFont(.body)
    473                         RotatingTaler(size: 100, progress: true, rotationEnabled: $rotationEnabled)
    474                             .frame(maxWidth: .infinity, alignment: .center)
    475                             // has its own accessibilityLabel
    476                     case .withdrawal(let withdrawalTransaction): Group {
    477                         let details = withdrawalTransaction.details
    478                         if common.isAborted && details.withdrawalDetails.type == .manual {
    479                             if let dayStr = abortedHint(details.withdrawalDetails.reserveClosingDelay) {
    480                                 Text("The withdrawal was aborted.\nIf you have already sent money to the payment service, it will wire it back in \(dayStr) days.")
    481                                     .talerFont(.callout)
    482                             }
    483                         }
    484                         if pending {
    485                             PendingWithdrawalDetails(stack: stack.push(),
    486                                                transaction: $transaction,
    487                                                    details: details)
    488                         } // ManualDetails or Confirm now (with bank)
    489                         ThreeAmountsSheet(stack: stack.push(),
    490                                           scope: scope,
    491                                          common: common,
    492                                       topAbbrev: String(localized: "Chosen:", comment: "mini"),
    493                                        topTitle: String(localized: "Chosen amount to withdraw:"),
    494                                         baseURL: details.exchangeBaseUrl,
    495                                          noFees: nil,               // TODO: noFees
    496                                   feeIsNegative: true,
    497                                           large: false,
    498                                         summary: nil,
    499                                        merchant: nil)
    500                     }
    501                     case .deposit(let depositTransaction): Group {
    502                         if transaction.common.isPendingKYCauth {
    503                             KYCauth(stack: stack.push(), common: transaction.common)
    504                         } else if transaction.isPendingKYC {
    505                             KYCbutton(kycUrl: common.kycUrl)
    506                         }
    507                         ThreeAmountsSheet(stack: stack.push(),
    508                                           scope: scope,
    509                                          common: common,
    510                                       topAbbrev: String(localized: "Deposit:", comment: "mini"),
    511                                        topTitle: String(localized: "Amount to deposit:"),
    512                                         baseURL: nil,               // TODO: baseURL
    513                                          noFees: nil,               // TODO: noFees
    514                                   feeIsNegative: false,
    515                                           large: true,
    516                                         summary: nil,
    517                                        merchant: nil)
    518                     }
    519                     case .payment(let paymentTransaction): Group {
    520                         let details = paymentTransaction.details
    521                         if common.isDialog {
    522                             let firstScope = common.scopes.first
    523                             PaymentView2(stack: stack.push(),
    524                                           paid: false,
    525                                            raw: common.amountRaw,
    526                                      effective: common.amountEffective,
    527                                     firstScope: firstScope,
    528                                        baseURL: nil,
    529                                        summary: details.info.summary,
    530                                       merchant: details.info.merchant.name,
    531                                       products: details.info.products,
    532                                 balanceDetails: nil)
    533 
    534                         } else {
    535                             TransactionPayDetailV(paymentTx: paymentTransaction)
    536                             ThreeAmountsSheet(stack: stack.push(),
    537                                           scope: scope,
    538                                          common: common,
    539                                       topAbbrev: String(localized: "Price:", comment: "mini"),
    540                                        topTitle: String(localized: "Price (net):"),
    541                                         baseURL: nil,               // TODO: baseURL
    542                                          noFees: nil,               // TODO: noFees
    543                                   feeIsNegative: false,
    544                                           large: true,
    545                                         summary: details.info.summary,
    546                                        merchant: details.info.merchant.name)
    547                         }
    548                     }
    549                     case .refund(let refundTransaction): Group {
    550                         let details = refundTransaction.details                 // TODO: more details
    551                         ThreeAmountsSheet(stack: stack.push(),
    552                                           scope: scope,
    553                                          common: common,
    554                                       topAbbrev: String(localized: "Refunded:", comment: "mini"),
    555                                        topTitle: String(localized: "Refunded amount:"),
    556                                         baseURL: nil,               // TODO: baseURL
    557                                          noFees: nil,               // TODO: noFees
    558                                   feeIsNegative: true,
    559                                           large: true,
    560                                         summary: details.info?.summary,
    561                                        merchant: details.info?.merchant.name)
    562                     }
    563                     case .refresh(let refreshTransaction): Group {
    564                         let labelColor = WalletColors().labelColor
    565                         let errorColor = WalletColors().errorColor
    566                         let details = refreshTransaction.details
    567                         Section {
    568                             Text(details.refreshReason.localizedRefreshReason)
    569                                 .talerFont(.title)
    570                             let input = details.refreshInputAmount
    571                             AmountRowV(stack: stack.push(),
    572                                        title: minimalistic ? "Refreshed:" : "Refreshed amount:",
    573                                       amount: input,
    574                                        scope: scope,
    575                                   isNegative: nil,
    576                                        color: labelColor,
    577                                        large: true)
    578                             if let fee = refreshFee(input: input, output: details.refreshOutputAmount) {
    579                                 AmountRowV(stack: stack.push(),
    580                                            title: minimalistic ? "Fee:" : "Refreshed fee:",
    581                                           amount: fee,
    582                                            scope: scope,
    583                                       isNegative: fee.isZero ? nil : true,
    584                                            color: labelColor,
    585                                            large: true)
    586                             }
    587                             if let error = details.error {
    588                                 HStack {
    589                                     VStack(alignment: .leading) {
    590                                         Text(error.hint)
    591                                             .talerFont(.headline)
    592                                             .foregroundColor(errorColor)
    593                                             .listRowSeparator(.hidden)
    594                                         if let stack = error.stack {
    595                                             Text(stack)
    596                                                 .talerFont(.body)
    597                                                 .foregroundColor(errorColor)
    598                                                 .listRowSeparator(.hidden)
    599                                         }
    600                                     }
    601                                     let stackStr = error.stack ?? EMPTYSTRING
    602                                     let errorStr = error.hint + "\n" + stackStr
    603                                     CopyButton(textToCopy: errorStr, vertical: true)
    604                                         .accessibilityLabel(Text("Copy the error", comment: "a11y"))
    605                                         .disabled(false)
    606                                 }
    607                             }
    608                         }
    609                     }
    610 
    611                     case .peer2peer(let p2pTransaction): Group {
    612                         let details = p2pTransaction.details
    613                         if transaction.isPendingKYC {
    614                             KYCbutton(kycUrl: common.kycUrl)
    615                         }
    616                         if !transaction.isDone {
    617                             ExpiresView(expiration: details.info.expiration)
    618                         }
    619                         if transaction.isRcvCoins && common.isDialog {
    620                             PeerPushCreditView(stack: stack.push(),
    621                                                  raw: common.amountRaw,
    622                                            effective: common.amountEffective,
    623                                                scope: scope,
    624                                              summary: details.info.summary)
    625                             PeerPushCreditAccept(stack: stack.push(), url: nil,
    626                                          transactionId: common.transactionId,
    627                                                 accept: $ignoreAccept)
    628                         } else if transaction.isPayInvoice && common.isDialog {
    629                             PeerPullDebitView(stack: stack.push(),
    630                                                 raw: common.amountRaw,
    631                                           effective: common.amountEffective,
    632                                               scope: scope,
    633                                             summary: details.info.summary)
    634                             PeerPullDebitConfirm(stack: stack.push(), url: nil,
    635                                          transactionId: common.transactionId)
    636                         } else {
    637                         // 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
    638                         if pending {
    639                             if transaction.isPendingReady {
    640                                 QRCodeDetails(transaction: transaction)
    641                                 if hasDone {
    642                                     Text("QR code and link can also be scanned or copied / shared from Transactions later.")
    643                                         .multilineTextAlignment(.leading)
    644                                         .talerFont(.subheadline)
    645                                         .padding(.top)
    646                                 }
    647                             } else {
    648                                 Text("This transaction is not yet ready...")
    649                                     .multilineTextAlignment(.leading)
    650                                     .talerFont(.subheadline)
    651                             }
    652                         }
    653                         let colon = ":"
    654                         let localizedType = transaction.isDone ? transaction.localizedTypePast
    655                                                                : transaction.localizedType
    656                         ThreeAmountsSheet(stack: stack.push(),
    657                                           scope: scope,
    658                                          common: common,
    659                                       topAbbrev: localizedType + colon,
    660                                        topTitle: localizedType + colon,
    661                                         baseURL: details.exchangeBaseUrl,
    662                                          noFees: nil,         // TODO: noFees
    663                                   feeIsNegative: true,
    664                                           large: false,
    665                                         summary: details.info.summary,
    666                                        merchant: nil)
    667                         } // else
    668                     } // p2p
    669 
    670                     case .recoup(let recoupTransaction): Group {
    671                         let details = recoupTransaction.details                 // TODO: more details
    672                         ThreeAmountsSheet(stack: stack.push(),
    673                                           scope: scope,
    674                                          common: common,
    675                                       topAbbrev: String(localized: "Recoup:", comment: "mini"),
    676                                        topTitle: String(localized: "Recoup:"),
    677                                         baseURL: nil,
    678                                          noFees: nil,
    679                                   feeIsNegative: nil,
    680                                           large: true,             // TODO: baseURL, noFees
    681                                         summary: nil,
    682                                        merchant: nil)
    683                     }
    684                     case .denomLoss(let denomLossTransaction): Group {
    685                         let details = denomLossTransaction.details              // TODO: more details
    686                         ThreeAmountsSheet(stack: stack.push(),
    687                                           scope: scope,
    688                                          common: common,
    689                                       topAbbrev: String(localized: "Lost:", comment: "mini"),
    690                                        topTitle: String(localized: "Money lost:"),
    691                                         baseURL: details.exchangeBaseUrl,
    692                                          noFees: nil,
    693                                   feeIsNegative: nil,
    694                                           large: true,             // TODO: baseURL, noFees
    695                                         summary: details.lossEventType.rawValue,
    696                                        merchant: nil)
    697                     }
    698                 } // switch
    699             } // Group
    700         }
    701     }
    702     // MARK: -
    703     struct QRCodeDetails: View {
    704         var transaction : TalerTransaction
    705         var body: some View {
    706             let details = transaction.detailsToShow()
    707             let keys = details.keys
    708             if keys.contains(TALERURI) {
    709                 if let talerURI = details[TALERURI] {
    710                     if talerURI.count > 10 {
    711                         QRCodeDetailView(talerURI: talerURI,
    712                                    talerCopyShare: talerURI,
    713                                          incoming: transaction.isP2pIncoming,
    714                                            amount: transaction.common.amountRaw,
    715                                             scope: transaction.common.scopes.first)
    716                                             // scopes shouldn't (- but might) be nil!
    717                     }
    718                 }
    719             } else if keys.contains(EXCHANGEBASEURL) {
    720                 if let baseURL = details[EXCHANGEBASEURL] {
    721                     Text("from \(baseURL.trimURL)", comment: "baseURL") 
    722                         .talerFont(.title2)
    723                         .padding(.bottom)
    724                 }
    725             }
    726         }
    727     }
    728 } // TransactionSummaryV
    729 // MARK: -
    730 #if DEBUG
    731 //struct TransactionSummary_Previews: PreviewProvider {
    732 //    static func deleteTransactionDummy(transactionId: String) async throws {}
    733 //    static func doneActionDummy() {}
    734 //    static var withdrawal = TalerTransaction(incoming: true,
    735 //                                         pending: true,
    736 //                                              id: "some withdrawal ID",
    737 //                                            time: Timestamp(from: 1_666_000_000_000))
    738 //    static var payment = TalerTransaction(incoming: false,
    739 //                                      pending: false,
    740 //                                           id: "some payment ID",
    741 //                                         time: Timestamp(from: 1_666_666_000_000))
    742 //    static func reloadActionDummy(transactionId: String) async -> TalerTransaction { return withdrawal }
    743 //    static var previews: some View {
    744 //        Group {
    745 //            TransactionSummaryV(transaction: withdrawal, reloadAction: reloadActionDummy, doneAction: doneActionDummy)
    746 //            TransactionSummaryV(transaction: payment, reloadAction: reloadActionDummy, deleteAction: deleteTransactionDummy)
    747 //        }
    748 //    }
    749 //}
    750 #endif