taler-ios

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

PaymentScan.swift (16734B)


      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 typealias Announce = (_ this: String) -> ()
     13 
     14 fileprivate func feeLabel(_ feeString: String) -> String {
     15     feeString.isEmpty ? EMPTYSTRING : String(localized: "+ \(feeString) fee")
     16 }
     17 
     18 /// The pay-template instantiation which already created an order at the merchant
     19 struct PreparedTemplate: Equatable {
     20     let key: String                 // the parameters that order was created for
     21     let transactionId: String
     22 }
     23 
     24 // MARK: -
     25 // Will be called either by the user scanning a <pay> QR code or tapping the provided link,
     26 // both from the shop's website - or even from a printed QR code.
     27 // We show the payment details in a sheet, and a "Confirm payment" / "Pay now" button.
     28 // This is also the final view after the user entered data of a <pay-template>.
     29 struct PaymentScan: View, Sendable {
     30     private let symLog = SymLogV(0)
     31     let stack: CallStack
     32 
     33     // the scanned URL
     34     let url: URL
     35     let template: Bool
     36     @Binding var amountToTransfer: Amount
     37     @Binding var summary: String
     38     let amountIsEditable: Bool                      //
     39     let summaryIsEditable: Bool                      //
     40     var prepared: Binding<PreparedTemplate?>? = nil // template only, lives in PayTemplateScan
     41 
     42     @EnvironmentObject private var model: WalletModel
     43     @EnvironmentObject private var controller: Controller
     44     @AppStorage("myListStyle") var myListStyle: MyListStyle = .automatic
     45 
     46     @State private var currencyInfo: CurrencyInfo = CurrencyInfo.zero(UNKNOWN)
     47     @State private var txId: String? = nil
     48 
     49     @State private var elapsed: Int = 0
     50     @State private var talerTX = TalerTransaction(dummyCurrency: DEMOCURRENCY)
     51 
     52     /// the parameters an already instantiated template order belongs to
     53     private var templateKey: String {
     54         url.trimmedString + LINEFEED
     55           + (amountIsEditable ? amountToTransfer.description : EMPTYSTRING) + LINEFEED
     56           + (summaryIsEditable ? summary : EMPTYSTRING)
     57     }
     58 
     59     @MainActor
     60     private func viewDidLoad() async {
     61 //        symLog.log(".task")
     62         if template {
     63             /// Instantiating a template creates a fresh order at the merchant, and this view is
     64             ///  pushed anew (with new @State) whenever the user navigates back and forward again
     65             ///  - so only instantiate once per amount/subject the user entered.
     66             let key = templateKey
     67             if let already = prepared?.wrappedValue, already.key == key {
     68                 txId = already.transactionId
     69                 return
     70             }
     71             if let templateResponse = try? await model.preparePayForTemplate(url.trimmedString,
     72                                                    amount: amountIsEditable ? amountToTransfer : nil,
     73                                                  summary: summaryIsEditable ? summary : nil) {
     74                 txId = templateResponse.transactionId
     75                 prepared?.wrappedValue = PreparedTemplate(key: key, transactionId: templateResponse.transactionId)
     76 //                preparePayResult = templateResponse
     77 //                let raw = templateResponse.amountRaw
     78 //                controller.updateAmount(raw, forSaved: url)
     79             }
     80         } else {
     81             if let payResponse = try? await model.preparePayForUri(url.trimmedString) {
     82                 txId = payResponse.transactionId
     83 //                let raw = payResponse.amountRaw
     84 //                controller.updateAmount(raw, forSaved: url)       // TODO: update scanned URL
     85             }
     86         }
     87         if let txId {
     88             if let finalResult = try? await model.waitTransactionState(txId, token: txId, major: .dialog) {
     89                 if let matched = finalResult.matched {
     90                     print(matched)
     91                 }
     92             }
     93         }
     94     }
     95 
     96     var body: some View {
     97         ZStack {
     98             if let txId {
     99                 TransactionSummaryList(stack: stack.push(),
    100                                transactionId: txId,
    101                                      talerTX: $talerTX,
    102                                     navTitle: nil,
    103                                      hasDone: true,                 // conclude payment
    104                                     showDone: .prominent,
    105                                          url: url,
    106                                  withActions: false)
    107 #if TALER_NIGHTLY2
    108 //            if let preparePayResult {
    109                 let status = preparePayResult.status
    110                 let paid = status == .alreadyConfirmed
    111                 let navTitle = paid ? String(localized: "Already paid", comment:"pay merchant navTitle")
    112                                     : String(localized: "Confirm Payment", comment:"pay merchant navTitle")
    113                 let list = List {
    114                     TransactionSummaryList.MerchantHeader(terms: terms)
    115 
    116                     if paid {
    117                         Text("You already paid for this article.")
    118                             .talerFont(.headline)
    119                         if let fulfillmentUrl = terms.fulfillmentURL {
    120                             if let destination = URL(string: fulfillmentUrl) {
    121                                 let buttonTitle = terms.fulfillmentMessage ?? String(localized: "Open merchant website")
    122                                 Link(buttonTitle, destination: destination)
    123                                     .buttonStyle(TalerButtonStyle(type: .bordered))
    124                                     .accessibilityHint(String(localized: "Will go to the merchant website.", comment: "a11y"))
    125                             }
    126                         }
    127                     } // You already paid
    128 
    129                 }
    130                 .listStyle(myListStyle.style).anyView
    131 #if OIM
    132                 .overlay { if #available(iOS 16.4, *) {
    133                     if controller.oimSheetActive {
    134                         OIMpayView(stack: stack.push(),
    135                                    amount: effective)
    136                     }
    137                 } }
    138 #endif
    139 
    140                 if #available(iOS 17.0, *) {
    141                     list.toolbarTitleDisplayMode(.inlineLarge)
    142                 } else {
    143                     list
    144                 }
    145 #endif
    146             } else {
    147                 LoadingView(stack: stack.push(), scopeInfo: nil, message: url.host)
    148                     .task { await viewDidLoad() }
    149             }
    150         }.onAppear() {
    151             symLog.log("onAppear")
    152             DebugViewC.shared.setSheetID(SHEET_PAYMENT, stack: stack.push())
    153         }
    154     }
    155 }
    156 // MARK: -
    157 // MARK: -
    158 struct PaymentView2: View, Sendable {
    159     let stack: CallStack
    160     let paid: Bool
    161     let raw: Amount
    162     let effective: Amount?
    163     let firstScope: ScopeInfo?
    164     let baseURL: String?
    165 //    let terms: MerchantContractTerms
    166     let summary: String?
    167     let balanceDetails: PaymentInsufficientBalanceDetails?
    168 
    169     func computeFee(raw: Amount?, eff: Amount?) -> Amount? {
    170         if let raw, let eff {
    171             // fee can only be computed if raw and eff are the same currency
    172             return try? Amount.diff(raw, eff)
    173         }
    174         return nil
    175     }
    176 
    177     var body: some View {
    178                 // TODO: show balanceDetails.balanceAvailable
    179                 let topTitle = paid ? String(localized: "Paid amount:")
    180                                     : String(localized: "Amount to pay:")
    181                 let topAbbrev =  paid ? String(localized: "Paid:", comment: "mini")
    182                                       : String(localized: "Pay:", comment: "mini")
    183                 let bottomTitle = paid ? String(localized: "Spent amount:")
    184                                        : String(localized: "Amount to spend:")
    185                 if let effective {  // payment possible
    186                     let fee = computeFee(raw: raw, eff: effective)
    187                     ThreeAmountsSection(stack: stack.push("PaymentView2"),
    188                                         scope: firstScope,
    189                                      topTitle: topTitle,
    190                                     topAbbrev: topAbbrev,
    191                                     topAmount: raw,
    192                                        noFees: nil,        // TODO: check baseURL for fees
    193                                           fee: fee,
    194                                 feeIsNegative: nil,
    195                                   bottomTitle: bottomTitle,
    196                                  bottomAbbrev: String(localized: "Effective:", comment: "mini"),
    197                                  bottomAmount: effective,
    198                                         large: false,
    199                                 pendingDialog: !paid,
    200                                        isDone: paid,
    201                                      incoming: false,
    202                                       baseURL: baseURL,
    203                                    txStateLcl: nil,
    204                                       summary: nil,     // summary already shown in PaymentView above choices
    205                     )
    206                     // TODO: payment: popup with all possible exchanges, check fees
    207                 } else if let balanceDetails {    // Insufficient
    208                     if let localizedCause = balanceDetails.causeHint?.localizedCause(raw.currencyStr) {
    209                         Text(localizedCause)
    210                             .talerFont(.headline)
    211                     }
    212                     ThreeAmountsSection(stack: stack.push(),
    213                                         scope: firstScope,
    214                                      topTitle: topTitle,
    215                                     topAbbrev: topAbbrev,
    216                                     topAmount: raw,
    217                                        noFees: nil,        // TODO: check baseURL for fees
    218                                           fee: nil,
    219                                 feeIsNegative: nil,
    220                                   bottomTitle: String(localized: "Amount available:"),
    221                                  bottomAbbrev: String(localized: "Available:", comment: "mini"),
    222                                  bottomAmount: balanceDetails.balanceAvailable,
    223                                         large: false,
    224                                 pendingDialog: false,       // TODO: true to always show currency the payment will be made in?
    225                                        isDone: false,
    226                                      incoming: false,
    227                                       baseURL: baseURL,
    228                                    txStateLcl: nil,
    229                                       summary: nil,     // summary already shown in PaymentView above choices
    230                     )
    231                 } else {
    232                     // TODO: Error - neither effective nor balanceDetails
    233                     // actually this is shown when effective is not yet loaded for a brief moment
    234                     // - so don't show an error here
    235 //                    Text("Error")
    236 //                        .talerFont(.body)
    237                 }
    238     }
    239 }
    240 // MARK: -
    241 struct PaySafeArea: View, Sendable {
    242     let symLog: SymLogV?
    243     let stack: CallStack
    244     let terms: MerchantContractTerms
    245     let amountString: String
    246     let amountA11y: String
    247     @Binding var isFinished: Bool?
    248     @Binding var payNow: Bool
    249 
    250     @EnvironmentObject private var controller: Controller
    251 
    252     @State private var wasTapped: Bool = false
    253     @State private var timeToPay: Int?
    254 
    255     var body: some View {
    256         let fiveMinutes = 5 * 60
    257         let button = Button("Pay \(amountString) now") {
    258             if !wasTapped {
    259                 wasTapped = true
    260                 controller.hapticNotification(.success)
    261                 symLog?.log("paying \(amountString) now")
    262 #if DEBUG       // 1 second delay
    263                 DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
    264                     payNow = true
    265                 }
    266 #else
    267                 payNow = true
    268 #endif
    269             }
    270         }.id("payNow")
    271             .accessibilityLabel(Text("Pay \(amountA11y) now", comment: "a11y"))
    272             .buttonStyle(TalerButtonStyle(type: .prominent, disabled: wasTapped))
    273             .disabled(wasTapped)
    274             .onChange(of: payNow) { newValue in
    275                 if !newValue { wasTapped = false }  // this attempt finished (success or failure) - allow retry
    276             }
    277             .padding(.horizontal)
    278             .task {
    279                 timeToPay = terms.timeToPay(true)
    280                 if let timeToPay {
    281                     if timeToPay >= fiveMinutes {
    282                         let nextCheck = timeToPay - fiveMinutes
    283                         DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(nextCheck)) {
    284                             self.timeToPay = terms.timeToPay(true)
    285                         }
    286                     } else if timeToPay < 2 {
    287                         isFinished = true
    288                     }
    289                 }
    290             }
    291 
    292         if let timeToPay {
    293             if timeToPay < fiveMinutes {
    294                 let view = VStack {
    295                     TimeView(String(localized: "Time to pay:"),
    296                                       seconds: timeToPay,
    297                                    isFinished: $isFinished)
    298                         .padding(.top)
    299                     button
    300                 }
    301                 if #available(iOS 26.0, *) {
    302                     view
    303                         .glassEffect(in: .rect(cornerRadius: 16.0))
    304                 } else {
    305                     view
    306                 }
    307             } else {
    308                 let _ = symLog?.log("timeToPay \(timeToPay) seconds not shown")
    309                 button
    310             }
    311         } else {
    312             let _ = symLog?.log("no timeToPay")
    313             button
    314         }
    315     }
    316 }
    317 // MARK: -
    318 #if false
    319 struct PaymentURIView_Previews: PreviewProvider {
    320     static var previews: some View {
    321         let merchant = Merchant(name: "Merchant")
    322         let extra = Extra(articleName: "articleName")
    323         let product = Product(description: "description")
    324         let terms = MerchantContractTerms(hWire: "hWire",
    325                                      wireMethod: "wireMethod",
    326                                         summary: "summary",
    327                                     summaryI18n: nil,
    328                                           nonce: "nonce",
    329                                          amount: Amount(currency: LONGCURRENCY, cent: 220),
    330                                     payDeadline: Timestamp.tomorrow(),
    331                                          maxFee: Amount(currency: LONGCURRENCY, cent: 20),
    332                                        merchant: merchant,
    333                                     merchantPub: "merchantPub",
    334                                    deliveryDate: nil,
    335                                deliveryLocation: nil,
    336                                       exchanges: [],
    337                                        products: [product],
    338                                  refundDeadline: Timestamp.tomorrow(),
    339                            wireTransferDeadline: Timestamp.tomorrow(),
    340                                       timestamp: Timestamp.now(),
    341                                         orderID: "orderID",
    342                                 merchantBaseURL: "merchantBaseURL",
    343                                  fulfillmentURL: "fulfillmentURL",
    344                                publicReorderURL: "publicReorderURL",
    345                              fulfillmentMessage: nil,
    346                          fulfillmentMessageI18n: nil,
    347                                      minimumAge: nil
    348 //                                        extra: extra,
    349 //                                     auditors: []
    350                                   )
    351         let details = PreparePayResult(status: PreparePayResultType.paymentPossible,
    352                                 transactionId: "txn:payment:012345",
    353                                 contractTerms: terms,
    354                             contractTermsHash: "termsHash",
    355                                     amountRaw: Amount(currency: LONGCURRENCY, cent: 220),
    356                               amountEffective: Amount(currency: LONGCURRENCY, cent: 240),
    357                                balanceDetails: nil,
    358                                          paid: nil
    359 //                               ,   talerUri: "talerURI"
    360         )
    361         let url = URL(string: "taler://pay/some_amount")!
    362         
    363 //        @State private var amount: Amount? = nil        // templateParam
    364 //        @State private var summary: String? = nil       // templateParam
    365 
    366         PaymentView(stack: CallStack("Preview"), url: url,
    367                  template: false, amountToTransfer: nil, summary: nil,
    368          amountIsEditable: false, summaryIsEditable: false,
    369          preparePayResult: details)
    370     }
    371 }
    372 #endif