taler-ios

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

PaymentView.swift (15007B)


      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.isEmpty ? EMPTYSTRING : String(localized: "+ \(feeString) fee")
     16 }
     17 
     18 // MARK: -
     19 // Will be called either by the user scanning a <pay> QR code or tapping the provided link,
     20 // both from the shop's website - or even from a printed QR code.
     21 // We show the payment details in a sheet, and a "Confirm payment" / "Pay now" button.
     22 // This is also the final view after the user entered data of a <pay-template>.
     23 struct PaymentView: View, Sendable {
     24     private let symLog = SymLogV(0)
     25     let stack: CallStack
     26 
     27     // the scanned URL
     28     let url: URL
     29     let template: Bool
     30     @Binding var amountToTransfer: Amount
     31     @Binding var summary: String
     32     let amountIsEditable: Bool                      //
     33     let summaryIsEditable: Bool                      //
     34 
     35     @EnvironmentObject private var model: WalletModel
     36     @EnvironmentObject private var controller: Controller
     37     @AppStorage("myListStyle") var myListStyle: MyListStyle = .automatic
     38 
     39     @State private var currencyInfo: CurrencyInfo = CurrencyInfo.zero(UNKNOWN)
     40     @State var txId: String? = nil
     41 
     42     @State private var selectedChoice: Int = 0
     43     @State private var elapsed: Int = 0
     44     @State private var talerTX = TalerTransaction(dummyCurrency: DEMOCURRENCY)
     45 
     46     @MainActor
     47     private func viewDidLoad() async {
     48 //        symLog.log(".task")
     49         if template {
     50             if let templateResponse = try? await model.preparePayForTemplate(url.absoluteString,
     51                                                    amount: amountIsEditable ? amountToTransfer : nil,
     52                                                  summary: summaryIsEditable ? summary : nil) {
     53                 txId = templateResponse.transactionId
     54 //                preparePayResult = templateResponse
     55 //                let raw = templateResponse.amountRaw
     56 //                controller.updateAmount(raw, forSaved: url)
     57             }
     58         } else {
     59             if let payResponse = try? await model.preparePayForUri(url.absoluteString) {
     60                 txId = payResponse.transactionId
     61 //                let raw = payResponse.amountRaw
     62 //                controller.updateAmount(raw, forSaved: url)       // TODO: update scanned URL
     63             }
     64         }
     65     }
     66 
     67     var body: some View {
     68         ZStack {
     69             if let txId {
     70                 TransactionSummaryList(stack: stack.push(),
     71                                transactionId: txId,
     72                                      talerTX: $talerTX,
     73                                     navTitle: nil,
     74                                      hasDone: true,
     75                                     showDone: .prominent,
     76                                          url: url,
     77                                  withActions: false)
     78 #if TALER_NIGHTLY2
     79 //            if let preparePayResult {
     80                 let status = preparePayResult.status
     81                 let paid = status == .alreadyConfirmed
     82                 let navTitle = paid ? String(localized: "Already paid", comment:"pay merchant navTitle")
     83                                     : String(localized: "Confirm Payment", comment:"pay merchant navTitle")
     84                 let list = List {
     85                     TransactionSummaryList.MerchantHeader(terms: terms)
     86 
     87                     if paid {
     88                         Text("You already paid for this article.")
     89                             .talerFont(.headline)
     90                         if let fulfillmentUrl = terms.fulfillmentURL {
     91                             if let destination = URL(string: fulfillmentUrl) {
     92                                 let buttonTitle = terms.fulfillmentMessage ?? String(localized: "Open merchant website")
     93                                 Link(buttonTitle, destination: destination)
     94                                     .buttonStyle(TalerButtonStyle(type: .bordered))
     95                                     .accessibilityHint(String(localized: "Will go to the merchant website.", comment: "a11y"))
     96                             }
     97                         }
     98                     } // You already paid
     99 
    100                 }
    101                 .listStyle(myListStyle.style).anyView
    102 #if OIM
    103                 .overlay { if #available(iOS 16.4, *) {
    104                     if controller.oimSheetActive {
    105                         OIMpayView(stack: stack.push(),
    106                                    amount: effective)
    107                     }
    108                 } }
    109 #endif
    110 
    111                 if #available(iOS 17.0, *) {
    112                     list.toolbarTitleDisplayMode(.inlineLarge)
    113                 } else {
    114                     list
    115                 }
    116 #endif
    117             } else {
    118                 LoadingView(stack: stack.push(), scopeInfo: nil, message: url.host)
    119                     .task { await viewDidLoad() }
    120             }
    121         }.onAppear() {
    122             symLog.log("onAppear")
    123             DebugViewC.shared.setSheetID(SHEET_PAYMENT)
    124         }
    125     }
    126 }
    127 // MARK: -
    128 // MARK: -
    129 struct PaymentView2: View, Sendable {
    130     let stack: CallStack
    131     let paid: Bool
    132     let raw: Amount
    133     let effective: Amount?
    134     let firstScope: ScopeInfo?
    135     let baseURL: String?
    136 //    let terms: MerchantContractTerms
    137     let summary: String?
    138     let products: [Product]?
    139     let balanceDetails: PaymentInsufficientBalanceDetails?
    140 
    141     func computeFee(raw: Amount?, eff: Amount?) -> Amount? {
    142         if let raw, let eff {
    143             return try! Amount.diff(raw, eff)      // TODO: different currencies
    144         }
    145         return nil
    146     }
    147 
    148     var body: some View {
    149                 // TODO: show balanceDetails.balanceAvailable
    150                 let topTitle = paid ? String(localized: "Paid amount:")
    151                                     : String(localized: "Amount to pay:")
    152                 let topAbbrev =  paid ? String(localized: "Paid:", comment: "mini")
    153                                       : String(localized: "Pay:", comment: "mini")
    154                 let bottomTitle = paid ? String(localized: "Spent amount:")
    155                                        : String(localized: "Amount to spend:")
    156                 if let effective {  // payment possible
    157                     let fee = computeFee(raw: raw, eff: effective)
    158                     ThreeAmountsSection(stack: stack.push("PaymentView2"),
    159                                         scope: firstScope,
    160                                      topTitle: topTitle,
    161                                     topAbbrev: topAbbrev,
    162                                     topAmount: raw,
    163                                        noFees: nil,        // TODO: check baseURL for fees
    164                                           fee: fee,
    165                                 feeIsNegative: nil,
    166                                   bottomTitle: bottomTitle,
    167                                  bottomAbbrev: String(localized: "Effective:", comment: "mini"),
    168                                  bottomAmount: effective,
    169                                         large: false,
    170                                       pending: !paid,
    171                                      incoming: false,
    172                                       baseURL: baseURL,
    173                                    txStateLcl: nil,
    174                                       summary: nil,     // summary already shown in PaymentView above choices
    175                                      products: products)
    176                     // TODO: payment: popup with all possible exchanges, check fees
    177                 } else if let balanceDetails {    // Insufficient
    178                     if let localizedCause = balanceDetails.causeHint?.localizedCause(raw.currencyStr) {
    179                         Text(localizedCause)
    180                             .talerFont(.headline)
    181                     }
    182                     ThreeAmountsSection(stack: stack.push(),
    183                                         scope: firstScope,
    184                                      topTitle: topTitle,
    185                                     topAbbrev: topAbbrev,
    186                                     topAmount: raw,
    187                                        noFees: nil,        // TODO: check baseURL for fees
    188                                           fee: nil,
    189                                 feeIsNegative: nil,
    190                                   bottomTitle: String(localized: "Amount available:"),
    191                                  bottomAbbrev: String(localized: "Available:", comment: "mini"),
    192                                  bottomAmount: balanceDetails.balanceAvailable,
    193                                         large: false,
    194                                       pending: false,
    195                                      incoming: false,
    196                                       baseURL: baseURL,
    197                                    txStateLcl: nil,
    198                                       summary: nil,     // summary already shown in PaymentView above choices
    199                                      products: products)
    200                 } else {
    201                     // TODO: Error - neither effective nor balanceDetails
    202                     Text("Error")
    203                         .talerFont(.body)
    204                 }
    205     }
    206 }
    207 // MARK: -
    208 struct PaySafeArea: View, Sendable {
    209     let symLog: SymLogV?
    210     let stack: CallStack
    211     let terms: MerchantContractTerms
    212     let amountString: String
    213     let amountA11y: String
    214     @Binding var payNow: Bool
    215 
    216     func timeToPay(_ terms: MerchantContractTerms) -> Int {
    217         if let milliseconds = try? terms.payDeadline.milliseconds() {
    218             let date = Date(milliseconds: milliseconds)
    219             let now = Date.now
    220             let timeInterval = now.timeIntervalSince(date)
    221             if timeInterval < 0 {
    222                 symLog?.log("\(timeInterval) seconds left to pay")
    223                 return Int(-timeInterval)
    224             } else {
    225                 symLog?.log("\(date) - \(now) = \(timeInterval)")
    226             }
    227         } else {
    228             symLog?.log("no milliseconds")
    229         }
    230         return 0
    231     }
    232 
    233     @ViewBuilder
    234     func timeView(_ timeToPay: Int) -> some View {
    235         let startDate = Date()
    236         HStack {
    237             Text("Time to pay:")
    238             TimelineView(.animation) { context in
    239                 let elapsed = Int(context.date.timeIntervalSince(startDate))
    240                 let seconds = timeToPay - elapsed
    241                 let text = Text(verbatim: "\(seconds)")
    242                 if #available(iOS 17.0, *) {
    243                     text
    244                         .contentTransition(.numericText(countsDown: true))
    245                         .animation(.default, value: elapsed)
    246                 } else if #available(iOS 16.4, *) {
    247                     text
    248                         .animation(.default, value: elapsed)
    249                 } else {
    250                     text
    251                 }
    252             }.monospacedDigit()
    253             Text("seconds")
    254         }.accessibilityElement(children: .combine)
    255     }
    256 
    257     var body: some View {
    258         let timeToPay = timeToPay(terms)
    259         let showTime = timeToPay > 0 && timeToPay < 300
    260         let view = VStack {
    261             if showTime {
    262                 timeView(timeToPay)
    263                     .padding(.top)
    264             } else {
    265                 let _ = symLog?.log("\(timeToPay) not shown")
    266             }
    267             Button("Pay \(amountString) now") { payNow = true }
    268                 .accessibilityLabel(Text("Pay \(amountA11y) now", comment: "a11y"))
    269                 .buttonStyle(TalerButtonStyle(type: .prominent))
    270                 .padding(.horizontal)
    271                 .padding(.bottom, 4)
    272         }
    273         if #available(iOS 26.0, *) {
    274             view
    275                 .glassEffect(in: .rect(cornerRadius: 16.0))
    276                 .padding(.horizontal)
    277         } else {
    278             view
    279         }
    280     }
    281 }
    282 // MARK: -
    283 #if false
    284 struct PaymentURIView_Previews: PreviewProvider {
    285     static var previews: some View {
    286         let merchant = Merchant(name: "Merchant")
    287         let extra = Extra(articleName: "articleName")
    288         let product = Product(description: "description")
    289         let terms = MerchantContractTerms(hWire: "hWire",
    290                                      wireMethod: "wireMethod",
    291                                         summary: "summary",
    292                                     summaryI18n: nil,
    293                                           nonce: "nonce",
    294                                          amount: Amount(currency: LONGCURRENCY, cent: 220),
    295                                     payDeadline: Timestamp.tomorrow(),
    296                                          maxFee: Amount(currency: LONGCURRENCY, cent: 20),
    297                                        merchant: merchant,
    298                                     merchantPub: "merchantPub",
    299                                    deliveryDate: nil,
    300                                deliveryLocation: nil,
    301                                       exchanges: [],
    302                                        products: [product],
    303                                  refundDeadline: Timestamp.tomorrow(),
    304                            wireTransferDeadline: Timestamp.tomorrow(),
    305                                       timestamp: Timestamp.now(),
    306                                         orderID: "orderID",
    307                                 merchantBaseURL: "merchantBaseURL",
    308                                  fulfillmentURL: "fulfillmentURL",
    309                                publicReorderURL: "publicReorderURL",
    310                              fulfillmentMessage: nil,
    311                          fulfillmentMessageI18n: nil,
    312                             wireFeeAmortization: 0,
    313                                      maxWireFee: Amount(currency: LONGCURRENCY, cent: 20),
    314                                      minimumAge: nil
    315 //                                        extra: extra,
    316 //                                     auditors: []
    317                                   )
    318         let details = PreparePayResult(status: PreparePayResultType.paymentPossible,
    319                                 transactionId: "txn:payment:012345",
    320                                 contractTerms: terms,
    321                             contractTermsHash: "termsHash",
    322                                     amountRaw: Amount(currency: LONGCURRENCY, cent: 220),
    323                               amountEffective: Amount(currency: LONGCURRENCY, cent: 240),
    324                                balanceDetails: nil,
    325                                          paid: nil
    326 //                               ,   talerUri: "talerURI"
    327         )
    328         let url = URL(string: "taler://pay/some_amount")!
    329         
    330 //        @State private var amount: Amount? = nil        // templateParam
    331 //        @State private var summary: String? = nil       // templateParam
    332 
    333         PaymentView(stack: CallStack("Preview"), url: url,
    334                  template: false, amountToTransfer: nil, summary: nil,
    335          amountIsEditable: false, summaryIsEditable: false,
    336          preparePayResult: details)
    337     }
    338 }
    339 #endif