PaymentScan.swift (16034B)
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 return try! Amount.diff(raw, eff) // TODO: different currencies 166 } 167 return nil 168 } 169 170 var body: some View { 171 // TODO: show balanceDetails.balanceAvailable 172 let topTitle = paid ? String(localized: "Paid amount:") 173 : String(localized: "Amount to pay:") 174 let topAbbrev = paid ? String(localized: "Paid:", comment: "mini") 175 : String(localized: "Pay:", comment: "mini") 176 let bottomTitle = paid ? String(localized: "Spent amount:") 177 : String(localized: "Amount to spend:") 178 if let effective { // payment possible 179 let fee = computeFee(raw: raw, eff: effective) 180 ThreeAmountsSection(stack: stack.push("PaymentView2"), 181 scope: firstScope, 182 topTitle: topTitle, 183 topAbbrev: topAbbrev, 184 topAmount: raw, 185 noFees: nil, // TODO: check baseURL for fees 186 fee: fee, 187 feeIsNegative: nil, 188 bottomTitle: bottomTitle, 189 bottomAbbrev: String(localized: "Effective:", comment: "mini"), 190 bottomAmount: effective, 191 large: false, 192 pendingDialog: !paid, 193 isDone: paid, 194 incoming: false, 195 baseURL: baseURL, 196 txStateLcl: nil, 197 summary: nil, // summary already shown in PaymentView above choices 198 products: products) 199 // TODO: payment: popup with all possible exchanges, check fees 200 } else if let balanceDetails { // Insufficient 201 if let localizedCause = balanceDetails.causeHint?.localizedCause(raw.currencyStr) { 202 Text(localizedCause) 203 .talerFont(.headline) 204 } 205 ThreeAmountsSection(stack: stack.push(), 206 scope: firstScope, 207 topTitle: topTitle, 208 topAbbrev: topAbbrev, 209 topAmount: raw, 210 noFees: nil, // TODO: check baseURL for fees 211 fee: nil, 212 feeIsNegative: nil, 213 bottomTitle: String(localized: "Amount available:"), 214 bottomAbbrev: String(localized: "Available:", comment: "mini"), 215 bottomAmount: balanceDetails.balanceAvailable, 216 large: false, 217 pendingDialog: false, // TODO: true to always show currency the payment will be made in? 218 isDone: false, 219 incoming: false, 220 baseURL: baseURL, 221 txStateLcl: nil, 222 summary: nil, // summary already shown in PaymentView above choices 223 products: products) 224 } else { 225 // TODO: Error - neither effective nor balanceDetails 226 Text("Error") 227 .talerFont(.body) 228 } 229 } 230 } 231 // MARK: - 232 struct PaySafeArea: View, Sendable { 233 let symLog: SymLogV? 234 let stack: CallStack 235 let terms: MerchantContractTerms 236 let amountString: String 237 let amountA11y: String 238 @Binding var payNow: Bool 239 240 @EnvironmentObject private var controller: Controller 241 242 @State private var wasTapped: Bool = false 243 244 func timeToPay(_ terms: MerchantContractTerms) -> Int { 245 if let milliseconds = try? terms.payDeadline.milliseconds() { 246 let date = Date(milliseconds: milliseconds) 247 let now = Date.now 248 let timeInterval = now.timeIntervalSince(date) 249 if timeInterval < 0 { 250 symLog?.log("\(timeInterval) seconds left to pay") 251 return Int(-timeInterval) 252 } else { 253 symLog?.log("\(date) - \(now) = \(timeInterval)") 254 } 255 } else { 256 symLog?.log("no milliseconds") 257 } 258 return 0 259 } 260 261 var body: some View { 262 let timeToPay = timeToPay(terms) 263 let showTime = timeToPay > 0 && timeToPay < 300 264 let button = Button("Pay \(amountString) now") { 265 if !wasTapped { 266 wasTapped = true 267 controller.hapticNotification(.success) 268 symLog?.log("paying \(amountString) now") 269 #if DEBUG // 1 second delay 270 DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { 271 payNow = true 272 } 273 #else 274 payNow = true 275 #endif 276 } 277 } 278 .accessibilityLabel(Text("Pay \(amountA11y) now", comment: "a11y")) 279 .buttonStyle(TalerButtonStyle(type: .prominent, disabled: wasTapped)) 280 .disabled(wasTapped) 281 .padding(.horizontal) 282 283 if showTime { 284 let view = VStack { 285 TimeView(String(localized: "Time to pay:"), 286 seconds: timeToPay) 287 .padding(.top) 288 button 289 .padding(.bottom, 4) 290 } 291 if #available(iOS 26.0, *) { 292 view 293 .glassEffect(in: .rect(cornerRadius: 16.0)) 294 .padding(.horizontal) 295 } else { 296 view 297 } 298 } else { 299 let _ = symLog?.log("\(timeToPay) not shown") 300 button 301 } 302 } 303 } 304 // MARK: - 305 #if false 306 struct PaymentURIView_Previews: PreviewProvider { 307 static var previews: some View { 308 let merchant = Merchant(name: "Merchant") 309 let extra = Extra(articleName: "articleName") 310 let product = Product(description: "description") 311 let terms = MerchantContractTerms(hWire: "hWire", 312 wireMethod: "wireMethod", 313 summary: "summary", 314 summaryI18n: nil, 315 nonce: "nonce", 316 amount: Amount(currency: LONGCURRENCY, cent: 220), 317 payDeadline: Timestamp.tomorrow(), 318 maxFee: Amount(currency: LONGCURRENCY, cent: 20), 319 merchant: merchant, 320 merchantPub: "merchantPub", 321 deliveryDate: nil, 322 deliveryLocation: nil, 323 exchanges: [], 324 products: [product], 325 refundDeadline: Timestamp.tomorrow(), 326 wireTransferDeadline: Timestamp.tomorrow(), 327 timestamp: Timestamp.now(), 328 orderID: "orderID", 329 merchantBaseURL: "merchantBaseURL", 330 fulfillmentURL: "fulfillmentURL", 331 publicReorderURL: "publicReorderURL", 332 fulfillmentMessage: nil, 333 fulfillmentMessageI18n: nil, 334 minimumAge: nil 335 // extra: extra, 336 // auditors: [] 337 ) 338 let details = PreparePayResult(status: PreparePayResultType.paymentPossible, 339 transactionId: "txn:payment:012345", 340 contractTerms: terms, 341 contractTermsHash: "termsHash", 342 amountRaw: Amount(currency: LONGCURRENCY, cent: 220), 343 amountEffective: Amount(currency: LONGCURRENCY, cent: 240), 344 balanceDetails: nil, 345 paid: nil 346 // , talerUri: "talerURI" 347 ) 348 let url = URL(string: "taler://pay/some_amount")! 349 350 // @State private var amount: Amount? = nil // templateParam 351 // @State private var summary: String? = nil // templateParam 352 353 PaymentView(stack: CallStack("Preview"), url: url, 354 template: false, amountToTransfer: nil, summary: nil, 355 amountIsEditable: false, summaryIsEditable: false, 356 preparePayResult: details) 357 } 358 } 359 #endif