PaymentView.swift (15007B)
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 // MARK: - 19 // Will be called either by the user scanning a <pay> QR code or tapping the provided link, 20 // both from the shop's website - or even from a printed QR code. 21 // We show the payment details in a sheet, and a "Confirm payment" / "Pay now" button. 22 // This is also the final view after the user entered data of a <pay-template>. 23 struct PaymentView: View, Sendable { 24 private let symLog = SymLogV(0) 25 let stack: CallStack 26 27 // the scanned URL 28 let url: URL 29 let template: Bool 30 @Binding var amountToTransfer: Amount 31 @Binding var summary: String 32 let amountIsEditable: Bool // 33 let summaryIsEditable: Bool // 34 35 @EnvironmentObject private var model: WalletModel 36 @EnvironmentObject private var controller: Controller 37 @AppStorage("myListStyle") var myListStyle: MyListStyle = .automatic 38 39 @State private var currencyInfo: CurrencyInfo = CurrencyInfo.zero(UNKNOWN) 40 @State var txId: String? = nil 41 42 @State private var selectedChoice: Int = 0 43 @State private var elapsed: Int = 0 44 @State private var talerTX = TalerTransaction(dummyCurrency: DEMOCURRENCY) 45 46 @MainActor 47 private func viewDidLoad() async { 48 // symLog.log(".task") 49 if template { 50 if let templateResponse = try? await model.preparePayForTemplate(url.absoluteString, 51 amount: amountIsEditable ? amountToTransfer : nil, 52 summary: summaryIsEditable ? summary : nil) { 53 txId = templateResponse.transactionId 54 // preparePayResult = templateResponse 55 // let raw = templateResponse.amountRaw 56 // controller.updateAmount(raw, forSaved: url) 57 } 58 } else { 59 if let payResponse = try? await model.preparePayForUri(url.absoluteString) { 60 txId = payResponse.transactionId 61 // let raw = payResponse.amountRaw 62 // controller.updateAmount(raw, forSaved: url) // TODO: update scanned URL 63 } 64 } 65 } 66 67 var body: some View { 68 ZStack { 69 if let txId { 70 TransactionSummaryList(stack: stack.push(), 71 transactionId: txId, 72 talerTX: $talerTX, 73 navTitle: nil, 74 hasDone: true, 75 showDone: .prominent, 76 url: url, 77 withActions: false) 78 #if TALER_NIGHTLY2 79 // if let preparePayResult { 80 let status = preparePayResult.status 81 let paid = status == .alreadyConfirmed 82 let navTitle = paid ? String(localized: "Already paid", comment:"pay merchant navTitle") 83 : String(localized: "Confirm Payment", comment:"pay merchant navTitle") 84 let list = List { 85 TransactionSummaryList.MerchantHeader(terms: terms) 86 87 if paid { 88 Text("You already paid for this article.") 89 .talerFont(.headline) 90 if let fulfillmentUrl = terms.fulfillmentURL { 91 if let destination = URL(string: fulfillmentUrl) { 92 let buttonTitle = terms.fulfillmentMessage ?? String(localized: "Open merchant website") 93 Link(buttonTitle, destination: destination) 94 .buttonStyle(TalerButtonStyle(type: .bordered)) 95 .accessibilityHint(String(localized: "Will go to the merchant website.", comment: "a11y")) 96 } 97 } 98 } // You already paid 99 100 } 101 .listStyle(myListStyle.style).anyView 102 #if OIM 103 .overlay { if #available(iOS 16.4, *) { 104 if controller.oimSheetActive { 105 OIMpayView(stack: stack.push(), 106 amount: effective) 107 } 108 } } 109 #endif 110 111 if #available(iOS 17.0, *) { 112 list.toolbarTitleDisplayMode(.inlineLarge) 113 } else { 114 list 115 } 116 #endif 117 } else { 118 LoadingView(stack: stack.push(), scopeInfo: nil, message: url.host) 119 .task { await viewDidLoad() } 120 } 121 }.onAppear() { 122 symLog.log("onAppear") 123 DebugViewC.shared.setSheetID(SHEET_PAYMENT) 124 } 125 } 126 } 127 // MARK: - 128 // MARK: - 129 struct PaymentView2: View, Sendable { 130 let stack: CallStack 131 let paid: Bool 132 let raw: Amount 133 let effective: Amount? 134 let firstScope: ScopeInfo? 135 let baseURL: String? 136 // let terms: MerchantContractTerms 137 let summary: String? 138 let products: [Product]? 139 let balanceDetails: PaymentInsufficientBalanceDetails? 140 141 func computeFee(raw: Amount?, eff: Amount?) -> Amount? { 142 if let raw, let eff { 143 return try! Amount.diff(raw, eff) // TODO: different currencies 144 } 145 return nil 146 } 147 148 var body: some View { 149 // TODO: show balanceDetails.balanceAvailable 150 let topTitle = paid ? String(localized: "Paid amount:") 151 : String(localized: "Amount to pay:") 152 let topAbbrev = paid ? String(localized: "Paid:", comment: "mini") 153 : String(localized: "Pay:", comment: "mini") 154 let bottomTitle = paid ? String(localized: "Spent amount:") 155 : String(localized: "Amount to spend:") 156 if let effective { // payment possible 157 let fee = computeFee(raw: raw, eff: effective) 158 ThreeAmountsSection(stack: stack.push("PaymentView2"), 159 scope: firstScope, 160 topTitle: topTitle, 161 topAbbrev: topAbbrev, 162 topAmount: raw, 163 noFees: nil, // TODO: check baseURL for fees 164 fee: fee, 165 feeIsNegative: nil, 166 bottomTitle: bottomTitle, 167 bottomAbbrev: String(localized: "Effective:", comment: "mini"), 168 bottomAmount: effective, 169 large: false, 170 pending: !paid, 171 incoming: false, 172 baseURL: baseURL, 173 txStateLcl: nil, 174 summary: nil, // summary already shown in PaymentView above choices 175 products: products) 176 // TODO: payment: popup with all possible exchanges, check fees 177 } else if let balanceDetails { // Insufficient 178 if let localizedCause = balanceDetails.causeHint?.localizedCause(raw.currencyStr) { 179 Text(localizedCause) 180 .talerFont(.headline) 181 } 182 ThreeAmountsSection(stack: stack.push(), 183 scope: firstScope, 184 topTitle: topTitle, 185 topAbbrev: topAbbrev, 186 topAmount: raw, 187 noFees: nil, // TODO: check baseURL for fees 188 fee: nil, 189 feeIsNegative: nil, 190 bottomTitle: String(localized: "Amount available:"), 191 bottomAbbrev: String(localized: "Available:", comment: "mini"), 192 bottomAmount: balanceDetails.balanceAvailable, 193 large: false, 194 pending: false, 195 incoming: false, 196 baseURL: baseURL, 197 txStateLcl: nil, 198 summary: nil, // summary already shown in PaymentView above choices 199 products: products) 200 } else { 201 // TODO: Error - neither effective nor balanceDetails 202 Text("Error") 203 .talerFont(.body) 204 } 205 } 206 } 207 // MARK: - 208 struct PaySafeArea: View, Sendable { 209 let symLog: SymLogV? 210 let stack: CallStack 211 let terms: MerchantContractTerms 212 let amountString: String 213 let amountA11y: String 214 @Binding var payNow: Bool 215 216 func timeToPay(_ terms: MerchantContractTerms) -> Int { 217 if let milliseconds = try? terms.payDeadline.milliseconds() { 218 let date = Date(milliseconds: milliseconds) 219 let now = Date.now 220 let timeInterval = now.timeIntervalSince(date) 221 if timeInterval < 0 { 222 symLog?.log("\(timeInterval) seconds left to pay") 223 return Int(-timeInterval) 224 } else { 225 symLog?.log("\(date) - \(now) = \(timeInterval)") 226 } 227 } else { 228 symLog?.log("no milliseconds") 229 } 230 return 0 231 } 232 233 @ViewBuilder 234 func timeView(_ timeToPay: Int) -> some View { 235 let startDate = Date() 236 HStack { 237 Text("Time to pay:") 238 TimelineView(.animation) { context in 239 let elapsed = Int(context.date.timeIntervalSince(startDate)) 240 let seconds = timeToPay - elapsed 241 let text = Text(verbatim: "\(seconds)") 242 if #available(iOS 17.0, *) { 243 text 244 .contentTransition(.numericText(countsDown: true)) 245 .animation(.default, value: elapsed) 246 } else if #available(iOS 16.4, *) { 247 text 248 .animation(.default, value: elapsed) 249 } else { 250 text 251 } 252 }.monospacedDigit() 253 Text("seconds") 254 }.accessibilityElement(children: .combine) 255 } 256 257 var body: some View { 258 let timeToPay = timeToPay(terms) 259 let showTime = timeToPay > 0 && timeToPay < 300 260 let view = VStack { 261 if showTime { 262 timeView(timeToPay) 263 .padding(.top) 264 } else { 265 let _ = symLog?.log("\(timeToPay) not shown") 266 } 267 Button("Pay \(amountString) now") { payNow = true } 268 .accessibilityLabel(Text("Pay \(amountA11y) now", comment: "a11y")) 269 .buttonStyle(TalerButtonStyle(type: .prominent)) 270 .padding(.horizontal) 271 .padding(.bottom, 4) 272 } 273 if #available(iOS 26.0, *) { 274 view 275 .glassEffect(in: .rect(cornerRadius: 16.0)) 276 .padding(.horizontal) 277 } else { 278 view 279 } 280 } 281 } 282 // MARK: - 283 #if false 284 struct PaymentURIView_Previews: PreviewProvider { 285 static var previews: some View { 286 let merchant = Merchant(name: "Merchant") 287 let extra = Extra(articleName: "articleName") 288 let product = Product(description: "description") 289 let terms = MerchantContractTerms(hWire: "hWire", 290 wireMethod: "wireMethod", 291 summary: "summary", 292 summaryI18n: nil, 293 nonce: "nonce", 294 amount: Amount(currency: LONGCURRENCY, cent: 220), 295 payDeadline: Timestamp.tomorrow(), 296 maxFee: Amount(currency: LONGCURRENCY, cent: 20), 297 merchant: merchant, 298 merchantPub: "merchantPub", 299 deliveryDate: nil, 300 deliveryLocation: nil, 301 exchanges: [], 302 products: [product], 303 refundDeadline: Timestamp.tomorrow(), 304 wireTransferDeadline: Timestamp.tomorrow(), 305 timestamp: Timestamp.now(), 306 orderID: "orderID", 307 merchantBaseURL: "merchantBaseURL", 308 fulfillmentURL: "fulfillmentURL", 309 publicReorderURL: "publicReorderURL", 310 fulfillmentMessage: nil, 311 fulfillmentMessageI18n: nil, 312 wireFeeAmortization: 0, 313 maxWireFee: Amount(currency: LONGCURRENCY, cent: 20), 314 minimumAge: nil 315 // extra: extra, 316 // auditors: [] 317 ) 318 let details = PreparePayResult(status: PreparePayResultType.paymentPossible, 319 transactionId: "txn:payment:012345", 320 contractTerms: terms, 321 contractTermsHash: "termsHash", 322 amountRaw: Amount(currency: LONGCURRENCY, cent: 220), 323 amountEffective: Amount(currency: LONGCURRENCY, cent: 240), 324 balanceDetails: nil, 325 paid: nil 326 // , talerUri: "talerURI" 327 ) 328 let url = URL(string: "taler://pay/some_amount")! 329 330 // @State private var amount: Amount? = nil // templateParam 331 // @State private var summary: String? = nil // templateParam 332 333 PaymentView(stack: CallStack("Preview"), url: url, 334 template: false, amountToTransfer: nil, summary: nil, 335 amountIsEditable: false, summaryIsEditable: false, 336 preparePayResult: details) 337 } 338 } 339 #endif