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