taler-ios

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

TransactionSummaryList.swift (26440B)


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