taler-ios

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

TransactionSummaryList.swift (21855B)


      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 hasDone {
    107             if let transition = notification.userInfo?[TRANSACTIONTRANSITION] as? TransactionTransition {
    108                 if transition.transactionId == talerTX.common.transactionId {       // is the transition for THIS transaction?
    109                     symLog.log(logStr)
    110                     if talerTX.common.type.isPayment {
    111                         checkReload(notification, logStr)
    112                     } else {
    113                         dismissTop(stack.push())        // if this view is in a sheet then dissmiss the sheet
    114                         return true
    115                     }
    116                 }
    117             }
    118         } else { // no sheet but the details view -> reload
    119             checkReload(notification, logStr)
    120         }
    121         return false
    122     }
    123 
    124     @MainActor
    125     private func dismiss(_ stack: CallStack) {
    126         if hasDone {        // if this view is in a sheet then dissmiss the whole sheet
    127             dismissTop(stack.push())
    128         } else {            // on a NavigationStack just pop
    129             dismiss()
    130         }
    131     }
    132 
    133     func checkReload(_ notification: Notification, _ logStr: String = EMPTYSTRING) {
    134         if let transition = notification.userInfo?[TRANSACTIONTRANSITION] as? TransactionTransition {
    135             if transition.transactionId == transactionId {       // is the transition for THIS transaction?
    136                 let newMajor = transition.newTxState.major
    137                 Task { // runs on MainActor
    138                        // flush the screen first, then reload
    139                     withAnimation { talerTX = TalerTransaction(dummyCurrency: DEMOCURRENCY); viewId = UUID() }
    140                     symLog.log("newState: \(newMajor), reloading transaction")
    141                     if newMajor != .none {              // don't reload after delete
    142                         await loadTransaction()
    143                     }
    144                 }
    145             }
    146         } else { // Yikes - should never happen
    147 // TODO:      logger.warning("Can't get notification.userInfo as TransactionTransition")
    148             symLog.log(notification.userInfo as Any)
    149         }
    150     }
    151 
    152     func localizedState(_ txState: TransactionState) -> String {
    153         let major = txState.major
    154         if major != .failed {
    155             if let minorState = txState.minor {
    156                 if developerMode { return minorState.localizedDbgState }
    157 //                if talerTX.isPayment {
    158 //                  return String(localized: "Payment", comment: "TxMajorState heading")
    159 //                }
    160                 return minorState.localizedState ?? major.localizedState
    161             }
    162         }
    163         return major.localizedState
    164     }
    165 
    166     @ViewBuilder
    167     func dateAndStatus(_ common: TransactionCommon) -> some View {
    168         let (dateString, date) = TalerDater.dateString(common.timestamp, minimalistic)
    169         let a11yDate = TalerDater.accessibilityDate(date) ?? dateString
    170         Text(dateString)
    171             .talerFont(.body)
    172             .accessibilityLabel(a11yDate)
    173             .foregroundColor(WalletColors().secondary(colorScheme, colorSchemeContrast))
    174             .id(topID)
    175         let state = localizedState(common.txState)
    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         }   // TODO: a11y for tx icon
    185         if developerMode {
    186             if !jsonTransaction.isEmpty {
    187                 CopyButton(jsonTransaction, title: "Copy JSON")
    188             }
    189         }
    190     }
    191 
    192     @ViewBuilder
    193     func suspendResume(_ common: TransactionCommon) -> some View {
    194         if talerTX.isSuspendable {
    195             TransactionButton(transactionId: common.transactionId,
    196                                     command: .suspend,
    197                                     warning: nil,
    198                                  didExecute: $ignoreThis,
    199                                      action: model.suspendTransaction)
    200             .listRowSeparator(.hidden)
    201         }
    202         if talerTX.isResumable {
    203             TransactionButton(transactionId: common.transactionId,
    204                                     command: .resume,
    205                                     warning: nil,
    206                                  didExecute: $ignoreThis,
    207                                      action: model.resumeTransaction)
    208             .listRowSeparator(.hidden)
    209         }
    210     }
    211 
    212     @ViewBuilder
    213     func abortFailDelete(_ common: TransactionCommon) -> some View {
    214         if talerTX.isAbortable {
    215             let warning = String(localized: "Are you sure you want to abort this transaction?")
    216             TransactionButton(transactionId: common.transactionId,
    217                                     command: .abort,
    218                                     warning: warning,
    219                                  didExecute: $ignoreThis,
    220                                      action: model.abortTransaction)
    221         } // Abort button
    222         if talerTX.isFailable {
    223             let warning = String(localized: "Are you sure you want to abandon this transaction?")
    224             TransactionButton(transactionId: common.transactionId,
    225                                     command: .fail,
    226                                     warning: warning,
    227                                  didExecute: $ignoreThis,
    228                                      action: model.failTransaction)
    229         } // Fail button
    230         if talerTX.isDeleteable {
    231             let warning = String(localized: "Are you sure you want to delete this transaction?")
    232             TransactionButton(transactionId: common.transactionId,
    233                                     command: .delete,
    234                                     warning: warning,
    235                                  didExecute: $didDelete,
    236                                      action: model.deleteTransaction)
    237             .onChange(of: didDelete) { wasDeleted in
    238                 if wasDeleted {
    239                     symLog.log("wasDeleted -> dismiss view")
    240                     dismiss(stack)
    241                 }
    242             }
    243         } // Delete button
    244     }
    245 
    246     var body: some View {
    247 #if PRINT_CHANGES
    248         let _ = Self._printChanges()
    249         let _ = symLog.vlog()       // just to get the # to compare it with .onAppear & onDisappear
    250 #endif
    251         let common = talerTX.common
    252 //        let scope = common.scopes.first                                     // might be nil if scopes == []
    253         let locale = TalerDater.shared.locale
    254         let isPaying = talerTX.isPayment && talerTX.isDialog
    255         let navTitle2 = talerTX.isDone ? talerTX.localizedTypePast
    256                             : isPaying ? String(localized: "Confirm Payment", comment:"pay merchant navTitle")
    257                                        : talerTX.localizedType
    258         Group {
    259           if common.type != .dummy && transactionId == common.transactionId {
    260             let list = List {
    261                 if developerMode && withActions { suspendResume(common) }
    262                 if !isPaying {
    263                     Section {
    264                         VStack(alignment: .leading) {
    265                             dateAndStatus(common)
    266                                 .listRowSeparator(.hidden)
    267                                 .talerFont(.title)
    268                         }.overlay {
    269                             if common.isWorking {
    270                                 RotatingTaler(size: 80, progress: true, once: false,
    271                                    rotationEnabled: Binding.constant(true))
    272                             }
    273                         }
    274                     }
    275                 }
    276                 TransactionTypeDetail(stack: stack.push(),
    277                                 transaction: $talerTX,
    278                                      payNow: $payNow,
    279                              selectedChoice: $selectedChoice,
    280                                       scope: $scope,
    281                                   effective: $effective,
    282                                     hasDone: hasDone)
    283 
    284                 // TODO: Retry Countdown, Retry Now button
    285 //                if talerTX.isRetryable, let retryAction {
    286 //                    TransactionButton(transactionId: common.transactionId, command: .retry,
    287 //                                      warning: nil, action: abortAction)
    288 //                } // Retry button
    289                 if withActions { abortFailDelete(common) }
    290             }.id(viewId)    // change viewId to enforce a draw update
    291             .listStyle(myListStyle.style).anyView
    292             .background(FullBackground())
    293             .navigationBarBackButtonHidden(hasDone)
    294             .interactiveDismissDisabled(hasDone)           // can only use "Done" button to dismiss
    295             .safeAreaInset(edge: .bottom) {
    296                 if isPaying, case .payment(let paymentTransaction) = talerTX {
    297                     let details = paymentTransaction.details
    298                     if let effective, let url, let terms = details.contractTerms {
    299                         let formatted = effective.formatted(currencyInfo)
    300                         PaySafeArea(symLog: symLog,
    301                                      stack: stack.push(),
    302                                      terms: terms,
    303                               amountString: formatted.0,
    304                                 amountA11y: formatted.1,
    305                                     payNow: $payNow)
    306                     } else {
    307                         Button("Cancel") { dismissTop(stack.push()) }
    308                             .buttonStyle(TalerButtonStyle(type: .bordered))
    309                             .padding(.horizontal)
    310                     } // Cancel
    311                 } else if let showDone {
    312                     Button("Done") { dismissTop(stack.push()) }
    313                         .buttonStyle(TalerButtonStyle(type: showDone))
    314                         .padding(.horizontal)
    315                 }
    316             }
    317             .onChange(of: payNow) { payNow2 in
    318                 if payNow2 {
    319                     Task {
    320                         payNow = false
    321                         await payTransaction()
    322                     }
    323                 }
    324             }
    325             .onNotification(.TransactionExpired) { notification in
    326                 // TODO: Alert user that this tx just expired
    327                 if checkDismiss(notification, "newTxState.major == expired  => dismiss sheet") {
    328         // TODO:                  logger.info("newTxState.major == expired  => dismiss sheet")
    329                 }
    330             }
    331             .onNotification(.TransactionDone) { notification in
    332                 checkDismiss(notification, "newTxState.major == done  => dismiss sheet")
    333             }
    334             .onNotification(.DismissSheet) { notification in
    335                 checkDismiss(notification, "exchangeWaitReserve or withdrawCoins  => dismiss sheet")
    336             }
    337             .onNotification(.PendingReady) { notification in
    338                 checkReload(notification, "pending ready ==> reload for talerURI")
    339             }
    340             .onNotification(.TransactionStateTransition) { notification in
    341                 if !didDelete {
    342                     checkReload(notification, "some transition ==> reload")
    343                 }
    344             }
    345             .navigationTitle(navTitle ?? navTitle2)
    346 
    347             if #available(iOS 17.0, *) {
    348                 list
    349                     .scrollContentBackground(preferredColorScheme == 3 ? .hidden : .visible)
    350                     .toolbarTitleDisplayMode(.inlineLarge)
    351             } else {
    352                 list
    353             }
    354           } else {
    355             Color.clear
    356                 .frame(maxWidth: .infinity, maxHeight: .infinity)
    357                 .task {
    358                     symLog.log("task - load transaction")
    359                     await loadTransaction()
    360                 }
    361           } // else
    362         } // Group
    363         .onChange(of: scope) { newVal in
    364             if let newVal {
    365                 currencyInfo = controller.info(for: newVal) ?? CurrencyInfo.zero(UNKNOWN)
    366             }
    367         }
    368         .onAppear {
    369             symLog.log("onAppear")
    370             DebugViewC.shared.setViewID(VIEW_TRANSACTIONSUMMARY, stack: stack.push())
    371         }
    372         .onDisappear {
    373             symLog.log("onDisappear")
    374         }
    375     }
    376 } // TransactionSummaryList
    377     // MARK: -
    378     struct KYCbutton: View {
    379         let kycUrl: String?
    380 
    381         var body: some View {
    382             if let kycUrl {
    383                 if let destination = URL(string: kycUrl) {
    384                     LinkButton(destination: destination,
    385                                hintTitle: String(localized: "You need to pass a legitimization procedure.", comment: "KYC"),
    386                                buttonTitle: String(localized: "Open legitimization website", comment: "KYC"),
    387                                   a11yHint: String(localized: "Will go to legitimization website to permit this withdrawal.", comment: "a11y"),
    388                                      badge: NEEDS_KYC)
    389                 }
    390             }
    391         }
    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(stack.push(),
    427                               title: "Unknown withdrawal type",        // should not happen, so no L10N
    428                             message: withdrawalDetails.toJSON(),
    429                            copyable: true)
    430             } // switch
    431         }
    432     }
    433 // MARK: -
    434     struct QRCodeDetails: View {
    435         var transaction : TalerTransaction
    436         var body: some View {
    437             let details = transaction.detailsToShow()
    438             let keys = details.keys
    439             if keys.contains(TALERURI) {
    440                 if let talerURI = details[TALERURI] {
    441                     if talerURI.count > 10 {
    442                         QRCodeDetailView(talerURI: talerURI,
    443                                    talerCopyShare: talerURI,
    444                                          incoming: transaction.isP2pIncoming,
    445                                            amount: transaction.common.amountRaw,
    446                                             scope: transaction.common.scopes.first)
    447                                             // scopes shouldn't (- but might) be nil!
    448                     }
    449                 }
    450             } else if keys.contains(EXCHANGEBASEURL) {
    451                 if let baseURL = details[EXCHANGEBASEURL] {
    452                     Text("from \(baseURL.trimURL)", comment: "baseURL") 
    453                         .talerFont(.title2)
    454                         .padding(.bottom)
    455                 }
    456             }
    457         }
    458     }
    459 // MARK: -
    460 #if DEBUG
    461 //struct TransactionSummary_Previews: PreviewProvider {
    462 //    static func deleteTransactionDummy(transactionId: String) async throws {}
    463 //    static func doneActionDummy() {}
    464 //    static var withdrawal = TalerTransaction(incoming: true,
    465 //                                         pending: true,
    466 //                                              id: "some withdrawal ID",
    467 //                                            time: Timestamp(from: 1_666_000_000_000))
    468 //    static var payment = TalerTransaction(incoming: false,
    469 //                                      pending: false,
    470 //                                           id: "some payment ID",
    471 //                                         time: Timestamp(from: 1_666_666_000_000))
    472 //    static func reloadActionDummy(transactionId: String) async -> TalerTransaction { return withdrawal }
    473 //    static var previews: some View {
    474 //        Group {
    475 //            TransactionSummaryV(transaction: withdrawal, reloadAction: reloadActionDummy, doneAction: doneActionDummy)
    476 //            TransactionSummaryV(transaction: payment, reloadAction: reloadActionDummy)
    477 //        }
    478 //    }
    479 //}
    480 #endif