taler-ios

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

TransactionSummaryList.swift (25499B)


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