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