taler-ios

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

TransactionSummaryV.swift (39882B)


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