taler-ios

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

PaymentView.swift (20482B)


      1 /*
      2  * This file is part of GNU Taler, ©2022-25 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.count > 0 ? String(localized: "+ \(feeString) fee")
     16     : EMPTYSTRING
     17 }
     18 
     19 func templateFee(ppCheck: PreparePayResult?) -> Amount? {
     20     do {
     21         if let ppCheck {
     22             // Outgoing: fee = effective - raw
     23             if let effective = ppCheck.amountEffective { // , let raw = ppCheck.amountRaw {
     24                 let raw = ppCheck.amountRaw
     25                 let fee = try effective - raw
     26                 return fee
     27             }
     28         }
     29     } catch {}
     30     return nil
     31 }
     32 
     33 /// at the moment the merchant doesn't provide live fee updates, but only after creating the payment. Thus we cannot show life fees...
     34 //struct PayForTemplateResult {
     35 //    let ppCheck: PreparePayResult
     36 //    let insufficient: Bool
     37 //    let feeAmount: Amount?
     38 //    let feeStr: String
     39 //}
     40 //
     41 //func preparePayForTemplate(model: WalletModel,
     42 //                             url: URL,
     43 //                          amount: Amount?,
     44 //                         summary: String?,
     45 //                        announce: Announce)
     46 //  async -> PayForTemplateResult? {
     47 //    if let ppCheck = try? await model.preparePayForTemplateM(url.absoluteString, amount: amount, summary: summary) {
     48 //        let controller = Controller.shared
     49 //        let amountRaw = ppCheck.amountRaw
     50 //        let currency = amountRaw.currencyStr
     51 //        let currencyInfo = controller.info(for: currency, controller.currencyTicker)
     52 //        let amountVoiceOver = amountRaw.formatted(currencyInfo, isNegative: false)
     53 //        let insufficient = ppCheck.status == .insufficientBalance
     54 //        if let feeAmount = templateFee(ppCheck: ppCheck) {
     55 //            let feeStr = feeAmount.formatted(currencyInfo, isNegative: false)
     56 //            let feeLabel = feeLabel(feeStr)
     57 //            announce("\(amountVoiceOver), \(feeLabel)")
     58 //            return PayForTemplateResult(ppCheck: ppCheck, insufficient: insufficient,
     59 //                                        feeAmount: feeAmount, feeStr: feeStr)
     60 //        }
     61 //        announce(amountVoiceOver)
     62 //        return PayForTemplateResult(ppCheck: ppCheck, insufficient: insufficient,
     63 //                                    feeAmount: nil, feeStr: EMPTYSTRING)
     64 //    }
     65 //    return nil
     66 //}
     67 
     68 // MARK: -
     69 // Will be called either by the user scanning a <pay> QR code or tapping the provided link,
     70 // both from the shop's website - or even from a printed QR code.
     71 // We show the payment details in a sheet, and a "Confirm payment" / "Pay now" button.
     72 // This is also the final view after the user entered data of a <pay-template>.
     73 struct PaymentView: View, Sendable {
     74     private let symLog = SymLogV(0)
     75     let stack: CallStack
     76 
     77     // the scanned URL
     78     let url: URL
     79     let template: Bool
     80     @Binding var amountToTransfer: Amount
     81     @Binding var summary: String
     82     let amountIsEditable: Bool                      //
     83     let summaryIsEditable: Bool                      //
     84 
     85     @EnvironmentObject private var model: WalletModel
     86     @EnvironmentObject private var controller: Controller
     87     @AppStorage("myListStyle") var myListStyle: MyListStyle = .automatic
     88 
     89     @State private var currencyInfo: CurrencyInfo = CurrencyInfo.zero(UNKNOWN)
     90     @State var preparePayResult: PreparePayResult? = nil
     91     @State private var elapsed: Int = 0
     92 
     93     @MainActor
     94     func checkCurrencyInfo(for result: PreparePayResult) async {
     95         if let scopes = result.scopes {
     96             if scopes.count > 0 {
     97                 for scope in scopes {
     98                     controller.checkInfo(for: scope, model: model)
     99                 }
    100                 return
    101             }
    102         }
    103         // else fallback to contractTerms.exchanges
    104         // TODO: wallet-core should return status==UnknownCurrency. When we get that,
    105         //      we should let the user decide whether they want info about that 
    106 //        let exchanges = result.contractTerms.exchanges
    107 //        for exchange in exchanges {
    108 //            let baseUrl = exchange.url
    109                         // Yikes - getExchangeByUrl does NOT query the exchange via network,
    110                         // but returns "exchange entry not found in wallet database"
    111                         // so it doesn't make sense to call it here
    112 //            if let someExchange = try? await model.getExchangeByUrl(url: baseUrl) {
    113 //                symLog.log("\(baseUrl.trimURL) loaded")
    114 //                await controller.checkCurrencyInfo(for: baseUrl, model: model)
    115 //                symLog.log("Info(for: \(baseUrl.trimURL) loaded")
    116 //                return
    117 //            }
    118 //        }
    119         symLog.log("Couldn't load Info(for: \(result.amountRaw.currencyStr))")
    120     }
    121 
    122     @MainActor
    123     private func viewDidLoad() async {
    124 //        symLog.log(".task")
    125         if template {
    126             if let templateResponse = try? await model.preparePayForTemplate(url.absoluteString,
    127                                                    amount: amountIsEditable ? amountToTransfer : nil,
    128                                                  summary: summaryIsEditable ? summary : nil) {
    129                 await checkCurrencyInfo(for: templateResponse)
    130                 preparePayResult = templateResponse
    131                 let raw = templateResponse.amountRaw
    132                 controller.updateAmount(raw, forSaved: url)
    133             }
    134         } else {
    135             if let payResponse = try? await model.preparePayForUri(url.absoluteString) {
    136                 let raw = payResponse.amountRaw
    137                 amountToTransfer = raw
    138                 await checkCurrencyInfo(for: payResponse)
    139                 preparePayResult = payResponse
    140                 controller.updateAmount(raw, forSaved: url)
    141             }
    142         }
    143     }
    144 
    145     var body: some View {
    146       Group {
    147         if let preparePayResult {
    148             let status = preparePayResult.status
    149             let scopes = preparePayResult.scopes            // TODO: might be nil
    150             let firstScope = scopes?.first
    151             let raw = preparePayResult.amountRaw
    152 //            let currency = raw?.currencyStr ?? UNKNOWN      // TODO: v1 has currencies buried in choices
    153             let currency = raw.currencyStr
    154             let effective = preparePayResult.amountEffective
    155             let terms = preparePayResult.contractTerms
    156             let exchanges = terms.exchanges
    157             let baseURL = terms.exchanges.first?.url
    158             let paid = status == .alreadyConfirmed
    159             let navTitle = paid ? String(localized: "Already paid", comment:"pay merchant navTitle")
    160                                 : String(localized: "Confirm Payment", comment:"pay merchant navTitle")
    161             List {
    162                 if paid {
    163                     Text("You already paid for this article.")
    164                         .talerFont(.headline)
    165                     if let fulfillmentUrl = terms.fulfillmentURL {
    166                         if let destination = URL(string: fulfillmentUrl) {
    167                             let buttonTitle = terms.fulfillmentMessage ?? String(localized: "Open merchant website")
    168                             Link(buttonTitle, destination: destination)
    169                                 .buttonStyle(TalerButtonStyle(type: .bordered))
    170                                 .accessibilityHint(String(localized: "Will go to the merchant website.", comment: "a11y"))
    171                         }
    172                     }
    173                 }
    174 
    175                 PaymentView2(stack: stack.push(),
    176                               paid: paid,
    177                                raw: raw,
    178                          effective: effective,
    179                         firstScope: firstScope,
    180                            baseURL: baseURL,
    181 //                             terms: terms,
    182                            summary: terms.summary,
    183                           merchant: terms.merchant.name,
    184                           products: terms.products,
    185                     balanceDetails: preparePayResult.balanceDetails)
    186 
    187             }
    188             .listStyle(myListStyle.style).anyView
    189             .safeAreaInset(edge: .bottom) {
    190                 if !paid {
    191                     if let effective {
    192                         PaySafeArea(symLog: symLog,
    193                                     stack: stack.push(),
    194                                     terms: terms,
    195                                       url: url,
    196                                 effective: effective,
    197                             transactionId: preparePayResult.transactionId,
    198                              currencyInfo: $currencyInfo)
    199                     } else {
    200                         Button("Cancel") {
    201                             dismissTop(stack.push())
    202                         }
    203                         .buttonStyle(TalerButtonStyle(type: .bordered))
    204                         .padding(.horizontal)
    205                     } // Cancel
    206                 }
    207             }
    208             .navigationTitle(navTitle)
    209             .task(id: controller.currencyTicker) {
    210                 let currency = amountToTransfer.currencyStr
    211                 let scopes = preparePayResult.scopes            // TODO: might be nil
    212                 if let resultScope = scopes?.first {            // TODO: let user choose which currency
    213                     currencyInfo = controller.info(for: resultScope, controller.currencyTicker)
    214                 } else {
    215                     currencyInfo = controller.info2(for: currency, controller.currencyTicker)
    216                 }
    217                 symLog.log("Info(for: \(currency)) loaded: \(currencyInfo.name)")
    218             }
    219 #if OIM
    220             .overlay { if #available(iOS 16.4, *) {
    221                 if controller.oimSheetActive {
    222                     OIMpayView(stack: stack.push(),
    223                                amount: effective)
    224                 }
    225             } }
    226 #endif
    227         } else {
    228             LoadingView(stack: stack.push(), scopeInfo: nil, message: url.host)
    229                 .task { await viewDidLoad() }
    230         }
    231       }.onAppear() {
    232           symLog.log("onAppear")
    233           DebugViewC.shared.setSheetID(SHEET_PAYMENT)
    234       }
    235     }
    236 }
    237 // MARK: -
    238 struct PaymentView2: View, Sendable {
    239     let stack: CallStack
    240     let paid: Bool
    241     let raw: Amount
    242     let effective: Amount?
    243     let firstScope: ScopeInfo?
    244     let baseURL: String?
    245 //    let terms: MerchantContractTerms
    246     let summary: String?
    247     let merchant: String?
    248     let products: [Product]?
    249     let balanceDetails: PayMerchantInsufficientBalanceDetails?
    250 
    251     func computeFee(raw: Amount?, eff: Amount?) -> Amount? {
    252         if let raw, let eff {
    253             return try! Amount.diff(raw, eff)      // TODO: different currencies
    254         }
    255         return nil
    256     }
    257 
    258     var body: some View {
    259                 // TODO: show balanceDetails.balanceAvailable
    260                 let topTitle = paid ? String(localized: "Paid amount:")
    261                                     : String(localized: "Amount to pay:")
    262                 let topAbbrev =  paid ? String(localized: "Paid:", comment: "mini")
    263                                       : String(localized: "Pay:", comment: "mini")
    264                 let bottomTitle = paid ? String(localized: "Spent amount:")
    265                                        : String(localized: "Amount to spend:")
    266                 if let effective {
    267                     let fee = computeFee(raw: raw, eff: effective)
    268                     ThreeAmountsSection(stack: stack.push(),
    269                                         scope: firstScope,
    270                                      topTitle: topTitle,
    271                                     topAbbrev: topAbbrev,
    272                                     topAmount: raw,
    273                                        noFees: nil,        // TODO: check baseURL for fees
    274                                           fee: fee,
    275                                 feeIsNegative: nil,
    276                                   bottomTitle: bottomTitle,
    277                                  bottomAbbrev: String(localized: "Effective:", comment: "mini"),
    278                                  bottomAmount: effective,
    279                                         large: false,
    280                                       pending: false,
    281                                      incoming: false,
    282                                       baseURL: baseURL,
    283                                    txStateLcl: nil,
    284                                       summary: summary,
    285                                      merchant: merchant,
    286                                      products: products)
    287                     // TODO: payment: popup with all possible exchanges, check fees
    288                 } else if let balanceDetails {    // Insufficient
    289                     let localizedCause = balanceDetails.causeHint.localizedCause(raw.currencyStr)
    290                     Text(localizedCause)
    291                         .talerFont(.headline)
    292                     ThreeAmountsSection(stack: stack.push(),
    293                                         scope: firstScope,
    294                                      topTitle: topTitle,
    295                                     topAbbrev: topAbbrev,
    296                                     topAmount: raw,
    297                                        noFees: nil,        // TODO: check baseURL for fees
    298                                           fee: nil,
    299                                 feeIsNegative: nil,
    300                                   bottomTitle: String(localized: "Amount available:"),
    301                                  bottomAbbrev: String(localized: "Available:", comment: "mini"),
    302                                  bottomAmount: balanceDetails.balanceAvailable,
    303                                         large: false,
    304                                       pending: false,
    305                                      incoming: false,
    306                                       baseURL: baseURL,
    307                                    txStateLcl: nil,
    308                                       summary: summary,
    309                                      merchant: merchant,
    310                                      products: products)
    311                 } else {
    312                     // TODO: Error - neither effective nor balanceDetails
    313                     Text("Error")
    314                         .talerFont(.body)
    315                 }
    316 
    317     }
    318 }
    319 // MARK: -
    320 struct PaySafeArea: View, Sendable {
    321     let symLog: SymLogV?
    322     let stack: CallStack
    323     let terms: MerchantContractTerms
    324     // the scanned URL
    325     let url: URL
    326     let effective: Amount
    327     let transactionId: String
    328     @Binding var currencyInfo: CurrencyInfo
    329 
    330     func timeToPay(_ terms: MerchantContractTerms) -> Int {
    331         if let milliseconds = try? terms.payDeadline.milliseconds() {
    332             let date = Date(milliseconds: milliseconds)
    333             let now = Date.now
    334             let timeInterval = now.timeIntervalSince(date)
    335             if timeInterval < 0 {
    336                 symLog?.log("\(timeInterval) seconds left to pay")
    337                 return Int(-timeInterval)
    338             } else {
    339                 symLog?.log("\(date) - \(now) = \(timeInterval)")
    340             }
    341         } else {
    342             symLog?.log("no milliseconds")
    343         }
    344         return 0
    345     }
    346 
    347     var body: some View {
    348         let timeToPay = timeToPay(terms)
    349         VStack {
    350             if timeToPay > 0 && timeToPay < 300 {
    351                 let startDate = Date()
    352                 HStack {
    353                     Text("Time to pay:")
    354                     TimelineView(.animation) { context in
    355                         let elapsed = Int(context.date.timeIntervalSince(startDate))
    356                         let seconds = timeToPay - elapsed
    357                         let text = Text(verbatim: "\(seconds)")
    358                         if #available(iOS 17.0, *) {
    359                             text
    360                                 .contentTransition(.numericText(countsDown: true))
    361                                 .animation(.default, value: elapsed)
    362                         } else if #available(iOS 16.4, *) {
    363                             text
    364                                 .animation(.default, value: elapsed)
    365                         } else {
    366                             text
    367                         }
    368                     }.monospacedDigit()
    369                     Text("seconds")
    370                 }.accessibilityElement(children: .combine)
    371             } else {
    372                 let _ = symLog?.log("\(timeToPay) not shown")
    373             }
    374             let destination = PaymentDone(stack: stack.push(),
    375                                             url: url,
    376 //                                        scope: firstScope,    // TODO: let user choose which currency
    377                                   transactionId: transactionId)
    378             NavigationLink(destination: destination) {
    379                 let formatted = effective.formatted(currencyInfo, isNegative: false)
    380                 Text("Pay \(formatted.0) now")
    381                     .accessibilityLabel(Text("Pay \(formatted.1) now", comment: "a11y"))
    382             }
    383             .buttonStyle(TalerButtonStyle(type: .prominent))
    384             .padding(.horizontal)
    385             let currency = currencyInfo.currency
    386 //          let currency = amountToTransfer.currencyStr
    387             Text("Payment is made in \(currency)")
    388                 .talerFont(.callout)
    389         }
    390     }
    391 }
    392 // MARK: -
    393 #if false
    394 struct PaymentURIView_Previews: PreviewProvider {
    395     static var previews: some View {
    396         let merchant = Merchant(name: "Merchant")
    397         let extra = Extra(articleName: "articleName")
    398         let product = Product(description: "description")
    399         let terms = MerchantContractTerms(hWire: "hWire",
    400                                      wireMethod: "wireMethod",
    401                                         summary: "summary",
    402                                     summaryI18n: nil,
    403                                           nonce: "nonce",
    404                                          amount: Amount(currency: LONGCURRENCY, cent: 220),
    405                                     payDeadline: Timestamp.tomorrow(),
    406                                          maxFee: Amount(currency: LONGCURRENCY, cent: 20),
    407                                        merchant: merchant,
    408                                     merchantPub: "merchantPub",
    409                                    deliveryDate: nil,
    410                                deliveryLocation: nil,
    411                                       exchanges: [],
    412                                        products: [product],
    413                                  refundDeadline: Timestamp.tomorrow(),
    414                            wireTransferDeadline: Timestamp.tomorrow(),
    415                                       timestamp: Timestamp.now(),
    416                                         orderID: "orderID",
    417                                 merchantBaseURL: "merchantBaseURL",
    418                                  fulfillmentURL: "fulfillmentURL",
    419                                publicReorderURL: "publicReorderURL",
    420                              fulfillmentMessage: nil,
    421                          fulfillmentMessageI18n: nil,
    422                             wireFeeAmortization: 0,
    423                                      maxWireFee: Amount(currency: LONGCURRENCY, cent: 20),
    424                                      minimumAge: nil
    425 //                                        extra: extra,
    426 //                                     auditors: []
    427                                   )
    428         let details = PreparePayResult(status: PreparePayResultType.paymentPossible,
    429                                 transactionId: "txn:payment:012345",
    430                                 contractTerms: terms,
    431                             contractTermsHash: "termsHash",
    432                                     amountRaw: Amount(currency: LONGCURRENCY, cent: 220),
    433                               amountEffective: Amount(currency: LONGCURRENCY, cent: 240),
    434                                balanceDetails: nil,
    435                                          paid: nil
    436 //                               ,   talerUri: "talerURI"
    437         )
    438         let url = URL(string: "taler://pay/some_amount")!
    439         
    440 //        @State private var amount: Amount? = nil        // templateParam
    441 //        @State private var summary: String? = nil       // templateParam
    442 
    443         PaymentView(stack: CallStack("Preview"), url: url,
    444                  template: false, amountToTransfer: nil, summary: nil,
    445          amountIsEditable: false, summaryIsEditable: false,
    446          preparePayResult: details)
    447     }
    448 }
    449 #endif