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