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