taler-ios

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

TransactionSummaryList.swift (23554B)


      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                       // false: just old tx, true: the conclusion of an transaction
     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     @AppStorage("preferredColorScheme") var preferredColorScheme: Int = 0
     47 #if DEBUG
     48     @AppStorage("developerMode") var developerMode: Bool = true
     49 #else
     50     @AppStorage("developerMode") var developerMode: Bool = false
     51 #endif
     52 
     53     @State private var currencyInfo: CurrencyInfo = CurrencyInfo.zero(UNKNOWN)
     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? = nil
     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 {
     69                 talerTX = reloadedTransaction;
     70                 scope = reloadedTransaction.common.scopes.first
     71                 viewId = UUID()      // redraw
     72             }
     73             if developerMode {
     74                 if let json = try? await model.jsonTransactionById(transactionId,
     75                                               includeContractTerms: true, viewHandles: false) {
     76                     jsonTransaction = json
     77                 } else {
     78                     jsonTransaction = EMPTYSTRING
     79                 }
     80             }
     81         } else {
     82             withAnimation{ talerTX = TalerTransaction(dummyCurrency: DEMOCURRENCY); viewId = UUID() }
     83             jsonTransaction = EMPTYSTRING
     84         }
     85     }
     86 
     87     private func payTransaction() async {
     88         if let confirmPayResult = try? await model.confirmPay(transactionId,
     89                                                   choiceIndex: selectedChoice) {
     90 //          symLog.log(confirmPayResult as Any)
     91             if confirmPayResult.type == "done" {
     92                 if let url {
     93                     controller.removeURL(url)
     94                 }
     95             } else {
     96                 if let url {
     97                     controller.removeURL(url)    // TODO: pending might fail - in which case we might want to try again
     98                 }
     99             }
    100         }
    101     }
    102 
    103     @MainActor
    104     @discardableResult
    105     func checkDismiss(_ notification: Notification, _ logStr: String = EMPTYSTRING) -> Bool {
    106         if let transition = notification.userInfo?[TRANSACTIONTRANSITION] as? TransactionTransition {
    107             if transition.transactionId == talerTX.common.transactionId {       // is the transition for THIS transaction?
    108                 if transition.newTxState.major == .deleted {
    109                     dismissTop(stack.push())        // dissmiss the sheet / the navigation stack
    110                     return true
    111                 }
    112                 if hasDone {
    113                     symLog.log(logStr)
    114                     if talerTX.common.type.isPayment {
    115                         checkReload(stack.push("checkDismiss1"), notification, logStr)
    116                     } else {
    117                         dismissTop(stack.push())        // if this view is in a sheet then dissmiss the sheet
    118                         return true
    119                     }
    120                 } else { // no sheet but the details view -> reload
    121                     checkReload(stack.push("checkDismiss2"), notification, logStr)
    122                 }
    123             }
    124         }
    125         return false
    126     }
    127 
    128     @MainActor
    129     private func dismiss(_ stack: CallStack) {
    130         if hasDone {        // if this view is in a sheet then dissmiss the whole sheet
    131             dismissTop(stack.push())
    132         } else {            // on a NavigationStack just pop
    133             dismiss()
    134         }
    135     }
    136 
    137     func checkReload(_ stack: CallStack, _ notification: Notification, _ logStr: String = EMPTYSTRING) {
    138         if let transition: TransactionTransition = notification.userInfo?[TRANSACTIONTRANSITION] as? TransactionTransition {
    139             if transition.transactionId == transactionId {       // is the transition for THIS transaction?
    140                 let newMajor = transition.newTxState.major
    141                 let message = stack.peek()?.message ?? "?"
    142                 if newMajor == .deleted {
    143                     symLog.log("newState: \(newMajor), show 'deleted', for: \(message)")
    144                     let error = transition.errorInfo ??
    145                         TalerErrorDetail(code: 7049,  // WALLET_MERCHANT_ORDER_NOT_FOUND
    146                                          when: .now(),
    147                                          hint: "Transaction has been deleted")
    148                     model.setError(WalletBackendError.walletCoreError(error))
    149                 } else {
    150                     Task { // runs on MainActor
    151                            // flush the screen first, then reload
    152                         withAnimation { talerTX = TalerTransaction(dummyCurrency: DEMOCURRENCY); viewId = UUID() }
    153                         if newMajor != .none {              // don't reload after delete
    154                             symLog.log("newState: \(newMajor), reloading transaction, for: \(message)")
    155                             await loadTransaction()
    156                         }
    157                     }
    158                 }
    159             }
    160         } else { // Yikes - should never happen
    161 // TODO:      logger.warning("Can't get notification.userInfo as TransactionTransition")
    162             symLog.log(notification.userInfo as Any)
    163         }
    164     }
    165 
    166     func localizedState(_ txState: TransactionState) -> String {
    167         let major = txState.major
    168         if major != .failed {
    169             if let minorState = txState.minor {
    170                 if developerMode { return minorState.localizedDbgState }
    171 //                if talerTX.isPayment {
    172 //                  return String(localized: "Payment", comment: "TxMajorState heading")
    173 //                }
    174                 return minorState.localizedState ?? major.localizedState
    175             }
    176         }
    177         return major.localizedState
    178     }
    179 
    180     @ViewBuilder
    181     func dateAndStatus(_ common: TransactionCommon) -> some View {
    182         let (dateString, date) = TalerDater.dateString(common.timestamp, minimalistic)
    183         let a11yDate = TalerDater.accessibilityDate(date) ?? dateString
    184         Text(dateString)
    185             .talerFont(.body)
    186             .accessibilityLabel(a11yDate)
    187             .foregroundColor(WalletColors().secondary(colorScheme, colorSchemeContrast))
    188             .id(topID)
    189         let state = localizedState(common.txState)
    190         let statusT = Text(state)
    191             .multilineTextAlignment(.trailing)
    192         let imageT = Text(common.type.icon())
    193             .accessibilityHidden(true)
    194         HStack(alignment: .center, spacing: HSPACING) {
    195             imageT
    196             Spacer(minLength: 0)
    197             statusT
    198         }   // TODO: a11y for tx icon
    199         if developerMode {
    200             if !jsonTransaction.isEmpty {
    201                 CopyButton(jsonTransaction, title: "Copy JSON")
    202             }
    203         }
    204     }
    205 
    206     @ViewBuilder
    207     func suspendResume(_ common: TransactionCommon) -> some View {
    208         if talerTX.isSuspendable {
    209             TransactionButton(transactionId: common.transactionId,
    210                                     command: .suspend,
    211                                     warning: nil,
    212                                  didExecute: $ignoreThis,
    213                                      action: model.suspendTransaction)
    214             .listRowSeparator(.hidden)
    215         }
    216         if talerTX.isResumable {
    217             TransactionButton(transactionId: common.transactionId,
    218                                     command: .resume,
    219                                     warning: nil,
    220                                  didExecute: $ignoreThis,
    221                                      action: model.resumeTransaction)
    222             .listRowSeparator(.hidden)
    223         }
    224     }
    225 
    226     @ViewBuilder
    227     func abortFailDelete(_ common: TransactionCommon) -> some View {
    228         if talerTX.isAbortable {
    229             let warning = String(localized: "Are you sure you want to abort this transaction?")
    230             TransactionButton(transactionId: common.transactionId,
    231                                     command: .abort,
    232                                     warning: warning,
    233                                  didExecute: $ignoreThis,
    234                                      action: model.abortTransaction)
    235         } // Abort button
    236         if talerTX.isFailable {
    237             let warning = String(localized: "Are you sure you want to abandon this transaction?")
    238             TransactionButton(transactionId: common.transactionId,
    239                                     command: .fail,
    240                                     warning: warning,
    241                                  didExecute: $ignoreThis,
    242                                      action: model.failTransaction)
    243         } // Fail button
    244         if talerTX.isDeleteable {
    245             let warning = String(localized: "Are you sure you want to delete this transaction?")
    246             TransactionButton(transactionId: common.transactionId,
    247                                     command: .delete,
    248                                     warning: warning,
    249                                  didExecute: $didDelete,
    250                                      action: model.deleteTransaction)
    251             .onChange(of: didDelete) { wasDeleted in
    252                 if wasDeleted {
    253                     symLog.log("wasDeleted -> dismiss view")
    254                     dismiss(stack)
    255                 }
    256             }
    257         } // Delete button
    258     }
    259 
    260     var navTitleStr: String {
    261         let isPaying = talerTX.isPayment && talerTX.isDialog
    262         return talerTX.isDone ? talerTX.localizedTypePast
    263             : isPaying ? (effective != nil ? String(localized: "Confirm Payment", comment:"pay merchant navTitle")
    264                                            : String(localized: "Payment not possible", comment:"pay merchant navTitle"))
    265             : talerTX.localizedType
    266     }
    267 
    268     var body: some View {
    269 #if PRINT_CHANGES
    270         let _ = Self._printChanges()
    271         let _ = symLog.vlog()       // just to get the # to compare it with .onAppear & onDisappear
    272 #endif
    273         let common = talerTX.common
    274 //        let scope = common.scopes.first                                     // might be nil if scopes == []
    275         let locale = TalerDater.shared.locale
    276         let isPaying = talerTX.isPayment && talerTX.isDialog
    277         Group {
    278           if common.type != .dummy && transactionId == common.transactionId {
    279             let list = List {
    280                 if developerMode && withActions { suspendResume(common) }
    281                 if !isPaying {
    282                     Section {
    283                         VStack(alignment: .leading) {
    284                             dateAndStatus(common)
    285                                 .listRowSeparator(.hidden)
    286                                 .talerFont(.title)
    287                         }.overlay {
    288                             if common.isWorking {
    289                                 RotatingTaler(size: 80, progress: true, once: false,
    290                                    rotationEnabled: Binding.constant(true))
    291                             }
    292                         }
    293                     }
    294                 }
    295                 TransactionTypeDetail(stack: stack.push(),
    296                                 transaction: $talerTX,
    297                                      payNow: $payNow,
    298                              selectedChoice: $selectedChoice,
    299                                       scope: $scope,
    300                                   effective: $effective,    // return amount for "Pay <amount> now"
    301                                     hasDone: hasDone)
    302 
    303                 // TODO: Retry Countdown, Retry Now button
    304 //                if talerTX.isRetryable, let retryAction {
    305 //                    TransactionButton(transactionId: common.transactionId, command: .retry,
    306 //                                      warning: nil, action: abortAction)
    307 //                } // Retry button
    308                 if withActions { abortFailDelete(common) }
    309             }.id(viewId)    // change viewId to enforce a draw update
    310             .listStyle(myListStyle.style).anyView
    311             .background(FullBackground())
    312             .navigationBarBackButtonHidden(hasDone)
    313             .interactiveDismissDisabled(hasDone && !isPaying)     // can only use "Done" button to dismiss
    314             .safeAreaInset(edge: .bottom) {
    315                 let bottomButton = ZStack {
    316                     if isPaying, case .payment(let paymentTransaction) = talerTX {
    317                         let details = paymentTransaction.details
    318                         if let effective, let terms = details.contractTerms {
    319                             let formatted = effective.formatted(currencyInfo)
    320                             PaySafeArea(symLog: symLog,
    321                                          stack: stack.push(),
    322                                          terms: terms,
    323                                   amountString: formatted.0,
    324                                     amountA11y: formatted.1,
    325                                         payNow: $payNow)
    326                         } else {
    327                             Button("Cancel") { dismissTop(stack.push()) }
    328                                 .buttonStyle(TalerButtonStyle(type: .bordered))
    329                                 .padding()
    330                         } // Cancel
    331                     } else if let showDone {
    332                         Button("Done") { dismissTop(stack.push()) }
    333                             .buttonStyle(TalerButtonStyle(type: showDone))
    334                             .padding()
    335                     }
    336                 }
    337                 if #available(iOS 26.0, *) {
    338                     bottomButton
    339                         .padding(.horizontal)
    340                 } else {
    341                     bottomButton
    342                         .padding()
    343                 }
    344             }
    345             .navigationTitle(navTitle ?? navTitleStr)
    346             .onChange(of: payNow) { payNow2 in
    347                 if payNow2 {
    348                     Task {
    349                         await payTransaction()
    350                         payNow = false   // re-enable "Pay now" if this attempt did not lead to a state transition
    351                     }
    352                 }
    353             }
    354             .onNotification(.TransactionExpired) { notification in
    355                 // TODO: Alert user that this tx just expired
    356                 if checkDismiss(notification, "newTxState.major == expired  => dismiss sheet") {
    357         // TODO:                  logger.info("newTxState.major == expired  => dismiss sheet")
    358                 }
    359             }
    360             .onNotification(.TransactionDone) { notification in
    361                 checkDismiss(notification, "newTxState.major == done  => dismiss sheet")
    362             }
    363             .onNotification(.DismissSheet) { notification in
    364                 checkDismiss(notification, "exchangeWaitReserve or withdrawCoins  => dismiss sheet")
    365             }
    366             .onNotification(.PendingReady) { notification in
    367                 checkReload(stack.push("pendingReady"), notification, "pending ready ==> reload for talerURI")
    368             }
    369             .onNotification(.TransactionStateTransition) { notification in
    370                 if !didDelete {
    371                     checkReload(stack.push("TransactionStateTransition"), notification, "some transition ==> reload")
    372                 }
    373             }
    374 
    375             if #available(iOS 17.0, *) {
    376                 list
    377                     .scrollContentBackground(preferredColorScheme == 3 ? .hidden : .visible)
    378                     .toolbarTitleDisplayMode(.inlineLarge)
    379             } else {
    380                 list
    381             }
    382           } else {
    383             Color.clear
    384                 .frame(maxWidth: .infinity, maxHeight: .infinity)
    385                 .task {
    386                     symLog.log("task - load transaction")
    387                     await loadTransaction()
    388                 }
    389           } // else
    390         } // Group
    391         .onChange(of: scope) { newVal in
    392             if let newVal {
    393                 currencyInfo = controller.info(for: newVal) ?? CurrencyInfo.zero(UNKNOWN)
    394             }
    395         }
    396         .onAppear {
    397             symLog.log("onAppear")
    398             DebugViewC.shared.setViewID(VIEW_TRANSACTIONSUMMARY, stack: stack.push())
    399         }
    400         .onDisappear {
    401             symLog.log("onDisappear")
    402         }
    403     }
    404 } // TransactionSummaryList
    405     // MARK: -
    406     struct KYCbutton: View {
    407         let kycUrl: String?
    408 
    409         var body: some View {
    410             if let kycUrl {
    411                 if let destination = URL(string: kycUrl) {
    412                     LinkButton(destination: destination,
    413                                hintTitle: String(localized: "You need to pass a legitimization procedure.", comment: "KYC"),
    414                                buttonTitle: String(localized: "Open legitimization website", comment: "KYC"),
    415                                   a11yHint: String(localized: "Will go to legitimization website to permit this withdrawal.", comment: "a11y"),
    416                                      badge: NEEDS_KYC)
    417                 }
    418             }
    419         }
    420     }
    421     // MARK: -
    422     struct PendingWithdrawalDetails: View {
    423         let stack: CallStack
    424         @Binding var transaction: TalerTransaction
    425         let details: WithdrawalTransactionDetails
    426 
    427         var body: some View {
    428             let common = transaction.common
    429             if transaction.isPendingKYC {
    430                 if let kycUrl = common.kycUrl {
    431                     KYCbutton(kycUrl: common.kycUrl)
    432                 } else {
    433                     Text("Legitimization procedure required", comment: "KYC")
    434                 }
    435             }
    436             let withdrawalDetails = details.withdrawalDetails
    437             switch withdrawalDetails.type {
    438                 case .manual:               // "Make a wire transfer of \(amount) to"
    439                     ManualDetailsV(stack: stack.push(), common: common, details: withdrawalDetails)
    440 
    441                 case .bankIntegrated:       // "Authorize now" (with bank)
    442                     if !transaction.isPendingKYC {              // cannot authorize if KYC is needed first
    443                         let confirmed = withdrawalDetails.confirmed ?? false
    444                         if !confirmed {
    445                             if let confirmationUrl = withdrawalDetails.bankConfirmationUrl {
    446                                 if let destination = URL(string: confirmationUrl) {
    447                                     LinkButton(destination: destination,
    448                                                  hintTitle: String(localized: "The bank is waiting for your authorization."),
    449                                                buttonTitle: String(localized: "Authorize now"),
    450                                                   a11yHint: String(localized: "Will go to bank website to authorize this withdrawal.", comment: "a11y"),
    451                                                      badge: CONFIRM_BANK)
    452                     }   }   }   }
    453                 @unknown default:
    454                     ErrorView(stack.push(),
    455                               title: "Unknown withdrawal type",        // should not happen, so no L10N
    456                             message: withdrawalDetails.toJSON(),
    457                            copyable: true)
    458             } // switch
    459         }
    460     }
    461 // MARK: -
    462     struct QRCodeDetails: View {
    463         var transaction : TalerTransaction
    464         var body: some View {
    465             let details = transaction.detailsToShow()
    466             let keys = details.keys
    467             if keys.contains(TALERURI) {
    468                 if let talerURI = details[TALERURI] {
    469                     if talerURI.count > 10 {
    470                         QRCodeDetailView(talerURI: talerURI,
    471                                    talerCopyShare: talerURI,
    472                                          incoming: transaction.isP2pIncoming,
    473                                            amount: transaction.common.amountRaw,
    474                                             scope: transaction.common.scopes.first)
    475                                             // scopes shouldn't (- but might) be nil!
    476                     }
    477                 }
    478             } else if keys.contains(EXCHANGEBASEURL) {
    479                 if let baseURL = details[EXCHANGEBASEURL] {
    480                     Text("from \(baseURL.trimURL)", comment: "baseURL") 
    481                         .talerFont(.title2)
    482                         .padding(.bottom)
    483                 }
    484             }
    485         }
    486     }
    487 // MARK: -
    488 #if DEBUG
    489 //struct TransactionSummary_Previews: PreviewProvider {
    490 //    static func deleteTransactionDummy(transactionId: String) async throws {}
    491 //    static func doneActionDummy() {}
    492 //    static var withdrawal = TalerTransaction(incoming: true,
    493 //                                         pending: true,
    494 //                                              id: "some withdrawal ID",
    495 //                                            time: Timestamp(from: 1_666_000_000_000))
    496 //    static var payment = TalerTransaction(incoming: false,
    497 //                                      pending: false,
    498 //                                           id: "some payment ID",
    499 //                                         time: Timestamp(from: 1_666_666_000_000))
    500 //    static func reloadActionDummy(transactionId: String) async -> TalerTransaction { return withdrawal }
    501 //    static var previews: some View {
    502 //        Group {
    503 //            TransactionSummaryV(transaction: withdrawal, reloadAction: reloadActionDummy, doneAction: doneActionDummy)
    504 //            TransactionSummaryV(transaction: payment, reloadAction: reloadActionDummy)
    505 //        }
    506 //    }
    507 //}
    508 #endif