PaymentView.swift (14485B)
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 // 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 elapsed: Int = 0 43 @State private var talerTX = TalerTransaction(dummyCurrency: DEMOCURRENCY) 44 45 @MainActor 46 private func viewDidLoad() async { 47 // symLog.log(".task") 48 if template { 49 if let templateResponse = try? await model.preparePayForTemplate(url.absoluteString, 50 amount: amountIsEditable ? amountToTransfer : nil, 51 summary: summaryIsEditable ? summary : nil) { 52 txId = templateResponse.transactionId 53 // preparePayResult = templateResponse 54 // let raw = templateResponse.amountRaw 55 // controller.updateAmount(raw, forSaved: url) 56 } 57 } else { 58 if let payResponse = try? await model.preparePayForUri(url.absoluteString) { 59 txId = payResponse.transactionId 60 // let raw = payResponse.amountRaw 61 // controller.updateAmount(raw, forSaved: url) // TODO: update scanned URL 62 } 63 } 64 } 65 66 var body: some View { 67 ZStack { 68 if let txId { 69 TransactionSummaryList(stack: stack.push(), 70 transactionId: txId, 71 talerTX: $talerTX, 72 navTitle: nil, 73 hasDone: true, 74 showDone: .prominent, 75 url: url, 76 withActions: false) 77 #if TALER_NIGHTLY2 78 // if let preparePayResult { 79 let status = preparePayResult.status 80 let paid = status == .alreadyConfirmed 81 let navTitle = paid ? String(localized: "Already paid", comment:"pay merchant navTitle") 82 : String(localized: "Confirm Payment", comment:"pay merchant navTitle") 83 let list = List { 84 TransactionSummaryList.MerchantHeader(terms: terms) 85 86 if paid { 87 Text("You already paid for this article.") 88 .talerFont(.headline) 89 if let fulfillmentUrl = terms.fulfillmentURL { 90 if let destination = URL(string: fulfillmentUrl) { 91 let buttonTitle = terms.fulfillmentMessage ?? String(localized: "Open merchant website") 92 Link(buttonTitle, destination: destination) 93 .buttonStyle(TalerButtonStyle(type: .bordered)) 94 .accessibilityHint(String(localized: "Will go to the merchant website.", comment: "a11y")) 95 } 96 } 97 } // You already paid 98 99 } 100 .listStyle(myListStyle.style).anyView 101 #if OIM 102 .overlay { if #available(iOS 16.4, *) { 103 if controller.oimSheetActive { 104 OIMpayView(stack: stack.push(), 105 amount: effective) 106 } 107 } } 108 #endif 109 110 if #available(iOS 17.0, *) { 111 list.toolbarTitleDisplayMode(.inlineLarge) 112 } else { 113 list 114 } 115 #endif 116 } else { 117 LoadingView(stack: stack.push(), scopeInfo: nil, message: url.host) 118 .task { await viewDidLoad() } 119 } 120 }.onAppear() { 121 symLog.log("onAppear") 122 DebugViewC.shared.setSheetID(SHEET_PAYMENT, stack: stack.push()) 123 } 124 } 125 } 126 // MARK: - 127 // MARK: - 128 struct PaymentView2: View, Sendable { 129 let stack: CallStack 130 let paid: Bool 131 let raw: Amount 132 let effective: Amount? 133 let firstScope: ScopeInfo? 134 let baseURL: String? 135 // let terms: MerchantContractTerms 136 let summary: String? 137 let products: [Product]? 138 let balanceDetails: PaymentInsufficientBalanceDetails? 139 140 func computeFee(raw: Amount?, eff: Amount?) -> Amount? { 141 if let raw, let eff { 142 return try! Amount.diff(raw, eff) // TODO: different currencies 143 } 144 return nil 145 } 146 147 var body: some View { 148 // TODO: show balanceDetails.balanceAvailable 149 let topTitle = paid ? String(localized: "Paid amount:") 150 : String(localized: "Amount to pay:") 151 let topAbbrev = paid ? String(localized: "Paid:", comment: "mini") 152 : String(localized: "Pay:", comment: "mini") 153 let bottomTitle = paid ? String(localized: "Spent amount:") 154 : String(localized: "Amount to spend:") 155 if let effective { // payment possible 156 let fee = computeFee(raw: raw, eff: effective) 157 ThreeAmountsSection(stack: stack.push("PaymentView2"), 158 scope: firstScope, 159 topTitle: topTitle, 160 topAbbrev: topAbbrev, 161 topAmount: raw, 162 noFees: nil, // TODO: check baseURL for fees 163 fee: fee, 164 feeIsNegative: nil, 165 bottomTitle: bottomTitle, 166 bottomAbbrev: String(localized: "Effective:", comment: "mini"), 167 bottomAmount: effective, 168 large: false, 169 pendingDialog: !paid, 170 isDone: 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 pendingDialog: false, // TODO: true to always show currency the payment will be made in? 195 isDone: false, 196 incoming: false, 197 baseURL: baseURL, 198 txStateLcl: nil, 199 summary: nil, // summary already shown in PaymentView above choices 200 products: products) 201 } else { 202 // TODO: Error - neither effective nor balanceDetails 203 Text("Error") 204 .talerFont(.body) 205 } 206 } 207 } 208 // MARK: - 209 struct PaySafeArea: View, Sendable { 210 let symLog: SymLogV? 211 let stack: CallStack 212 let terms: MerchantContractTerms 213 let amountString: String 214 let amountA11y: String 215 @Binding var payNow: Bool 216 @State private var wasTapped: Bool = false 217 218 func timeToPay(_ terms: MerchantContractTerms) -> Int { 219 if let milliseconds = try? terms.payDeadline.milliseconds() { 220 let date = Date(milliseconds: milliseconds) 221 let now = Date.now 222 let timeInterval = now.timeIntervalSince(date) 223 if timeInterval < 0 { 224 symLog?.log("\(timeInterval) seconds left to pay") 225 return Int(-timeInterval) 226 } else { 227 symLog?.log("\(date) - \(now) = \(timeInterval)") 228 } 229 } else { 230 symLog?.log("no milliseconds") 231 } 232 return 0 233 } 234 235 var body: some View { 236 let timeToPay = timeToPay(terms) 237 let showTime = timeToPay > 0 && timeToPay < 300 238 let button = Button("Pay \(amountString) now") { 239 if !wasTapped { 240 wasTapped = true 241 symLog?.log("paying \(amountString) now") 242 payNow = true 243 } 244 } 245 .accessibilityLabel(Text("Pay \(amountA11y) now", comment: "a11y")) 246 .buttonStyle(TalerButtonStyle(type: .prominent)) 247 .disabled(wasTapped) 248 .padding(.horizontal) 249 250 if showTime { 251 let view = VStack { 252 TimeView(String(localized: "Time to pay:"), 253 seconds: timeToPay) 254 .padding(.top) 255 button 256 .padding(.bottom, 4) 257 } 258 if #available(iOS 26.0, *) { 259 view 260 .glassEffect(in: .rect(cornerRadius: 16.0)) 261 .padding(.horizontal) 262 } else { 263 view 264 } 265 } else { 266 let _ = symLog?.log("\(timeToPay) not shown") 267 button 268 } 269 } 270 } 271 // MARK: - 272 #if false 273 struct PaymentURIView_Previews: PreviewProvider { 274 static var previews: some View { 275 let merchant = Merchant(name: "Merchant") 276 let extra = Extra(articleName: "articleName") 277 let product = Product(description: "description") 278 let terms = MerchantContractTerms(hWire: "hWire", 279 wireMethod: "wireMethod", 280 summary: "summary", 281 summaryI18n: nil, 282 nonce: "nonce", 283 amount: Amount(currency: LONGCURRENCY, cent: 220), 284 payDeadline: Timestamp.tomorrow(), 285 maxFee: Amount(currency: LONGCURRENCY, cent: 20), 286 merchant: merchant, 287 merchantPub: "merchantPub", 288 deliveryDate: nil, 289 deliveryLocation: nil, 290 exchanges: [], 291 products: [product], 292 refundDeadline: Timestamp.tomorrow(), 293 wireTransferDeadline: Timestamp.tomorrow(), 294 timestamp: Timestamp.now(), 295 orderID: "orderID", 296 merchantBaseURL: "merchantBaseURL", 297 fulfillmentURL: "fulfillmentURL", 298 publicReorderURL: "publicReorderURL", 299 fulfillmentMessage: nil, 300 fulfillmentMessageI18n: nil, 301 minimumAge: nil 302 // extra: extra, 303 // auditors: [] 304 ) 305 let details = PreparePayResult(status: PreparePayResultType.paymentPossible, 306 transactionId: "txn:payment:012345", 307 contractTerms: terms, 308 contractTermsHash: "termsHash", 309 amountRaw: Amount(currency: LONGCURRENCY, cent: 220), 310 amountEffective: Amount(currency: LONGCURRENCY, cent: 240), 311 balanceDetails: nil, 312 paid: nil 313 // , talerUri: "talerURI" 314 ) 315 let url = URL(string: "taler://pay/some_amount")! 316 317 // @State private var amount: Amount? = nil // templateParam 318 // @State private var summary: String? = nil // templateParam 319 320 PaymentView(stack: CallStack("Preview"), url: url, 321 template: false, amountToTransfer: nil, summary: nil, 322 amountIsEditable: false, summaryIsEditable: false, 323 preparePayResult: details) 324 } 325 } 326 #endif