taler-ios

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

PaymentScan.swift (16167B)


      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 + "\n"
     55           + (amountIsEditable ? amountToTransfer.description : EMPTYSTRING) + "\n"
     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     }
     88 
     89     var body: some View {
     90         ZStack {
     91             if let txId {
     92                 TransactionSummaryList(stack: stack.push(),
     93                                transactionId: txId,
     94                                      talerTX: $talerTX,
     95                                     navTitle: nil,
     96                                      hasDone: true,                 // conclude payment
     97                                     showDone: .prominent,
     98                                          url: url,
     99                                  withActions: false)
    100 #if TALER_NIGHTLY2
    101 //            if let preparePayResult {
    102                 let status = preparePayResult.status
    103                 let paid = status == .alreadyConfirmed
    104                 let navTitle = paid ? String(localized: "Already paid", comment:"pay merchant navTitle")
    105                                     : String(localized: "Confirm Payment", comment:"pay merchant navTitle")
    106                 let list = List {
    107                     TransactionSummaryList.MerchantHeader(terms: terms)
    108 
    109                     if paid {
    110                         Text("You already paid for this article.")
    111                             .talerFont(.headline)
    112                         if let fulfillmentUrl = terms.fulfillmentURL {
    113                             if let destination = URL(string: fulfillmentUrl) {
    114                                 let buttonTitle = terms.fulfillmentMessage ?? String(localized: "Open merchant website")
    115                                 Link(buttonTitle, destination: destination)
    116                                     .buttonStyle(TalerButtonStyle(type: .bordered))
    117                                     .accessibilityHint(String(localized: "Will go to the merchant website.", comment: "a11y"))
    118                             }
    119                         }
    120                     } // You already paid
    121 
    122                 }
    123                 .listStyle(myListStyle.style).anyView
    124 #if OIM
    125                 .overlay { if #available(iOS 16.4, *) {
    126                     if controller.oimSheetActive {
    127                         OIMpayView(stack: stack.push(),
    128                                    amount: effective)
    129                     }
    130                 } }
    131 #endif
    132 
    133                 if #available(iOS 17.0, *) {
    134                     list.toolbarTitleDisplayMode(.inlineLarge)
    135                 } else {
    136                     list
    137                 }
    138 #endif
    139             } else {
    140                 LoadingView(stack: stack.push(), scopeInfo: nil, message: url.host)
    141                     .task { await viewDidLoad() }
    142             }
    143         }.onAppear() {
    144             symLog.log("onAppear")
    145             DebugViewC.shared.setSheetID(SHEET_PAYMENT, stack: stack.push())
    146         }
    147     }
    148 }
    149 // MARK: -
    150 // MARK: -
    151 struct PaymentView2: View, Sendable {
    152     let stack: CallStack
    153     let paid: Bool
    154     let raw: Amount
    155     let effective: Amount?
    156     let firstScope: ScopeInfo?
    157     let baseURL: String?
    158 //    let terms: MerchantContractTerms
    159     let summary: String?
    160     let products: [Product]?
    161     let balanceDetails: PaymentInsufficientBalanceDetails?
    162 
    163     func computeFee(raw: Amount?, eff: Amount?) -> Amount? {
    164         if let raw, let eff {
    165             // fee can only be computed if raw and eff are the same currency
    166             return try? Amount.diff(raw, eff)
    167         }
    168         return nil
    169     }
    170 
    171     var body: some View {
    172                 // TODO: show balanceDetails.balanceAvailable
    173                 let topTitle = paid ? String(localized: "Paid amount:")
    174                                     : String(localized: "Amount to pay:")
    175                 let topAbbrev =  paid ? String(localized: "Paid:", comment: "mini")
    176                                       : String(localized: "Pay:", comment: "mini")
    177                 let bottomTitle = paid ? String(localized: "Spent amount:")
    178                                        : String(localized: "Amount to spend:")
    179                 if let effective {  // payment possible
    180                     let fee = computeFee(raw: raw, eff: effective)
    181                     ThreeAmountsSection(stack: stack.push("PaymentView2"),
    182                                         scope: firstScope,
    183                                      topTitle: topTitle,
    184                                     topAbbrev: topAbbrev,
    185                                     topAmount: raw,
    186                                        noFees: nil,        // TODO: check baseURL for fees
    187                                           fee: fee,
    188                                 feeIsNegative: nil,
    189                                   bottomTitle: bottomTitle,
    190                                  bottomAbbrev: String(localized: "Effective:", comment: "mini"),
    191                                  bottomAmount: effective,
    192                                         large: false,
    193                                 pendingDialog: !paid,
    194                                        isDone: paid,
    195                                      incoming: false,
    196                                       baseURL: baseURL,
    197                                    txStateLcl: nil,
    198                                       summary: nil,     // summary already shown in PaymentView above choices
    199                                      products: products)
    200                     // TODO: payment: popup with all possible exchanges, check fees
    201                 } else if let balanceDetails {    // Insufficient
    202                     if let localizedCause = balanceDetails.causeHint?.localizedCause(raw.currencyStr) {
    203                         Text(localizedCause)
    204                             .talerFont(.headline)
    205                     }
    206                     ThreeAmountsSection(stack: stack.push(),
    207                                         scope: firstScope,
    208                                      topTitle: topTitle,
    209                                     topAbbrev: topAbbrev,
    210                                     topAmount: raw,
    211                                        noFees: nil,        // TODO: check baseURL for fees
    212                                           fee: nil,
    213                                 feeIsNegative: nil,
    214                                   bottomTitle: String(localized: "Amount available:"),
    215                                  bottomAbbrev: String(localized: "Available:", comment: "mini"),
    216                                  bottomAmount: balanceDetails.balanceAvailable,
    217                                         large: false,
    218                                 pendingDialog: false,       // TODO: true to always show currency the payment will be made in?
    219                                        isDone: false,
    220                                      incoming: false,
    221                                       baseURL: baseURL,
    222                                    txStateLcl: nil,
    223                                       summary: nil,     // summary already shown in PaymentView above choices
    224                                      products: products)
    225                 } else {
    226                     // TODO: Error - neither effective nor balanceDetails
    227                     Text("Error")
    228                         .talerFont(.body)
    229                 }
    230     }
    231 }
    232 // MARK: -
    233 struct PaySafeArea: View, Sendable {
    234     let symLog: SymLogV?
    235     let stack: CallStack
    236     let terms: MerchantContractTerms
    237     let amountString: String
    238     let amountA11y: String
    239     @Binding var payNow: Bool
    240 
    241     @EnvironmentObject private var controller: Controller
    242 
    243     @State private var wasTapped: Bool = false
    244 
    245     func timeToPay(_ terms: MerchantContractTerms) -> Int {
    246         if let milliseconds = try? terms.payDeadline.milliseconds() {
    247             let date = Date(milliseconds: milliseconds)
    248             let now = Date.now
    249             let timeInterval = now.timeIntervalSince(date)
    250             if timeInterval < 0 {
    251                 symLog?.log("\(timeInterval) seconds left to pay")
    252                 return Int(-timeInterval)
    253             } else {
    254                 symLog?.log("\(date) - \(now) = \(timeInterval)")
    255             }
    256         } else {
    257             symLog?.log("no milliseconds")
    258         }
    259         return 0
    260     }
    261 
    262     var body: some View {
    263         let timeToPay = timeToPay(terms)
    264         let showTime = timeToPay > 0 && timeToPay < 300
    265         let button = Button("Pay \(amountString) now") {
    266             if !wasTapped {
    267                 wasTapped = true
    268                 controller.hapticNotification(.success)
    269                 symLog?.log("paying \(amountString) now")
    270 #if DEBUG       // 1 second delay
    271                 DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
    272                     payNow = true
    273                 }
    274 #else
    275                 payNow = true
    276 #endif
    277             }
    278         }
    279             .accessibilityLabel(Text("Pay \(amountA11y) now", comment: "a11y"))
    280             .buttonStyle(TalerButtonStyle(type: .prominent, disabled: wasTapped))
    281             .disabled(wasTapped)
    282             .onChange(of: payNow) { newValue in
    283                 if !newValue { wasTapped = false }  // this attempt finished (success or failure) - allow retry
    284             }
    285             .padding(.horizontal)
    286 
    287         if showTime {
    288             let view = VStack {
    289                 TimeView(String(localized: "Time to pay:"),
    290                                   seconds: timeToPay)
    291                     .padding(.top)
    292                 button
    293             }
    294             if #available(iOS 26.0, *) {
    295                 view
    296                     .glassEffect(in: .rect(cornerRadius: 16.0))
    297             } else {
    298                 view
    299             }
    300         } else {
    301             let _ = symLog?.log("\(timeToPay) not shown")
    302             button
    303         }
    304     }
    305 }
    306 // MARK: -
    307 #if false
    308 struct PaymentURIView_Previews: PreviewProvider {
    309     static var previews: some View {
    310         let merchant = Merchant(name: "Merchant")
    311         let extra = Extra(articleName: "articleName")
    312         let product = Product(description: "description")
    313         let terms = MerchantContractTerms(hWire: "hWire",
    314                                      wireMethod: "wireMethod",
    315                                         summary: "summary",
    316                                     summaryI18n: nil,
    317                                           nonce: "nonce",
    318                                          amount: Amount(currency: LONGCURRENCY, cent: 220),
    319                                     payDeadline: Timestamp.tomorrow(),
    320                                          maxFee: Amount(currency: LONGCURRENCY, cent: 20),
    321                                        merchant: merchant,
    322                                     merchantPub: "merchantPub",
    323                                    deliveryDate: nil,
    324                                deliveryLocation: nil,
    325                                       exchanges: [],
    326                                        products: [product],
    327                                  refundDeadline: Timestamp.tomorrow(),
    328                            wireTransferDeadline: Timestamp.tomorrow(),
    329                                       timestamp: Timestamp.now(),
    330                                         orderID: "orderID",
    331                                 merchantBaseURL: "merchantBaseURL",
    332                                  fulfillmentURL: "fulfillmentURL",
    333                                publicReorderURL: "publicReorderURL",
    334                              fulfillmentMessage: nil,
    335                          fulfillmentMessageI18n: nil,
    336                                      minimumAge: nil
    337 //                                        extra: extra,
    338 //                                     auditors: []
    339                                   )
    340         let details = PreparePayResult(status: PreparePayResultType.paymentPossible,
    341                                 transactionId: "txn:payment:012345",
    342                                 contractTerms: terms,
    343                             contractTermsHash: "termsHash",
    344                                     amountRaw: Amount(currency: LONGCURRENCY, cent: 220),
    345                               amountEffective: Amount(currency: LONGCURRENCY, cent: 240),
    346                                balanceDetails: nil,
    347                                          paid: nil
    348 //                               ,   talerUri: "talerURI"
    349         )
    350         let url = URL(string: "taler://pay/some_amount")!
    351         
    352 //        @State private var amount: Amount? = nil        // templateParam
    353 //        @State private var summary: String? = nil       // templateParam
    354 
    355         PaymentView(stack: CallStack("Preview"), url: url,
    356                  template: false, amountToTransfer: nil, summary: nil,
    357          amountIsEditable: false, summaryIsEditable: false,
    358          preparePayResult: details)
    359     }
    360 }
    361 #endif