taler-ios

iOS apps for GNU Taler (wallet)
Log | Files | Refs | README | LICENSE

commit d5af2295113c5fae1df23ac506cad7a69c1c570f
parent a06aff28aee937ca5a5806bcf589c9713f91a4e9
Author: Marc Stibane <marc@taler.net>
Date:   Fri,  7 Aug 2026 08:57:17 +0200

PaymentScan

Diffstat:
MTalerWallet1/Views/Sheets/Payment/PayTemplateScan.swift | 6+++---
ATalerWallet1/Views/Sheets/Payment/PaymentScan.swift | 326+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
DTalerWallet1/Views/Sheets/Payment/PaymentView.swift | 326-------------------------------------------------------------------------------
MTalerWallet1/Views/Sheets/URLSheet.swift | 2+-
4 files changed, 330 insertions(+), 330 deletions(-)

diff --git a/TalerWallet1/Views/Sheets/Payment/PayTemplateScan.swift b/TalerWallet1/Views/Sheets/Payment/PayTemplateScan.swift @@ -116,7 +116,7 @@ struct PayTemplateScan: View { let amountLabel = minimalistic ? String(localized: "Amount:") : a11yLabel // final destination with amountToTransfer, after user input of amount - let finalDestinationI = PaymentView(stack: stack.push(), + let finalDestinationI = PaymentScan(stack: stack.push(), url: url, template: true, amountToTransfer: $amountToTransfer, @@ -125,7 +125,7 @@ struct PayTemplateScan: View { summaryIsEditable: summaryIsEditable) // final destination with amountShortcut, when user tapped a shortcut - let finalDestinationS = PaymentView(stack: stack.push(), + let finalDestinationS = PaymentScan(stack: stack.push(), url: url, template: true, amountToTransfer: $amountShortcut, @@ -190,7 +190,7 @@ struct PayTemplateScan: View { } // inputDestination } else { // both template contract amount and summary are fixed => directly show the payment // Attention: contains a List, thus mustn't be included in a ScrollView - PaymentView(stack: stack.push(), + PaymentScan(stack: stack.push(), url: url, template: true, amountToTransfer: $amountToTransfer, diff --git a/TalerWallet1/Views/Sheets/Payment/PaymentScan.swift b/TalerWallet1/Views/Sheets/Payment/PaymentScan.swift @@ -0,0 +1,326 @@ +/* + * This file is part of GNU Taler, ©2022-26 Taler Systems S.A. + * See LICENSE.md + */ +/** + * @author Marc Stibane + */ +import SwiftUI +import taler_swift +import SymLog + +typealias Announce = (_ this: String) -> () + +fileprivate func feeLabel(_ feeString: String) -> String { + feeString.isEmpty ? EMPTYSTRING : String(localized: "+ \(feeString) fee") +} + +// MARK: - +// Will be called either by the user scanning a <pay> QR code or tapping the provided link, +// both from the shop's website - or even from a printed QR code. +// We show the payment details in a sheet, and a "Confirm payment" / "Pay now" button. +// This is also the final view after the user entered data of a <pay-template>. +struct PaymentScan: View, Sendable { + private let symLog = SymLogV(0) + let stack: CallStack + + // the scanned URL + let url: URL + let template: Bool + @Binding var amountToTransfer: Amount + @Binding var summary: String + let amountIsEditable: Bool // + let summaryIsEditable: Bool // + + @EnvironmentObject private var model: WalletModel + @EnvironmentObject private var controller: Controller + @AppStorage("myListStyle") var myListStyle: MyListStyle = .automatic + + @State private var currencyInfo: CurrencyInfo = CurrencyInfo.zero(UNKNOWN) + @State var txId: String? = nil + + @State private var elapsed: Int = 0 + @State private var talerTX = TalerTransaction(dummyCurrency: DEMOCURRENCY) + + @MainActor + private func viewDidLoad() async { +// symLog.log(".task") + if template { + if let templateResponse = try? await model.preparePayForTemplate(url.trimmedString, + amount: amountIsEditable ? amountToTransfer : nil, + summary: summaryIsEditable ? summary : nil) { + txId = templateResponse.transactionId +// preparePayResult = templateResponse +// let raw = templateResponse.amountRaw +// controller.updateAmount(raw, forSaved: url) + } + } else { + if let payResponse = try? await model.preparePayForUri(url.trimmedString) { + txId = payResponse.transactionId +// let raw = payResponse.amountRaw +// controller.updateAmount(raw, forSaved: url) // TODO: update scanned URL + } + } + } + + var body: some View { + ZStack { + if let txId { + TransactionSummaryList(stack: stack.push(), + transactionId: txId, + talerTX: $talerTX, + navTitle: nil, + hasDone: true, // conclude payment + showDone: .prominent, + url: url, + withActions: false) +#if TALER_NIGHTLY2 +// if let preparePayResult { + let status = preparePayResult.status + let paid = status == .alreadyConfirmed + let navTitle = paid ? String(localized: "Already paid", comment:"pay merchant navTitle") + : String(localized: "Confirm Payment", comment:"pay merchant navTitle") + let list = List { + TransactionSummaryList.MerchantHeader(terms: terms) + + if paid { + Text("You already paid for this article.") + .talerFont(.headline) + if let fulfillmentUrl = terms.fulfillmentURL { + if let destination = URL(string: fulfillmentUrl) { + let buttonTitle = terms.fulfillmentMessage ?? String(localized: "Open merchant website") + Link(buttonTitle, destination: destination) + .buttonStyle(TalerButtonStyle(type: .bordered)) + .accessibilityHint(String(localized: "Will go to the merchant website.", comment: "a11y")) + } + } + } // You already paid + + } + .listStyle(myListStyle.style).anyView +#if OIM + .overlay { if #available(iOS 16.4, *) { + if controller.oimSheetActive { + OIMpayView(stack: stack.push(), + amount: effective) + } + } } +#endif + + if #available(iOS 17.0, *) { + list.toolbarTitleDisplayMode(.inlineLarge) + } else { + list + } +#endif + } else { + LoadingView(stack: stack.push(), scopeInfo: nil, message: url.host) + .task { await viewDidLoad() } + } + }.onAppear() { + symLog.log("onAppear") + DebugViewC.shared.setSheetID(SHEET_PAYMENT, stack: stack.push()) + } + } +} +// MARK: - +// MARK: - +struct PaymentView2: View, Sendable { + let stack: CallStack + let paid: Bool + let raw: Amount + let effective: Amount? + let firstScope: ScopeInfo? + let baseURL: String? +// let terms: MerchantContractTerms + let summary: String? + let products: [Product]? + let balanceDetails: PaymentInsufficientBalanceDetails? + + func computeFee(raw: Amount?, eff: Amount?) -> Amount? { + if let raw, let eff { + return try! Amount.diff(raw, eff) // TODO: different currencies + } + return nil + } + + var body: some View { + // TODO: show balanceDetails.balanceAvailable + let topTitle = paid ? String(localized: "Paid amount:") + : String(localized: "Amount to pay:") + let topAbbrev = paid ? String(localized: "Paid:", comment: "mini") + : String(localized: "Pay:", comment: "mini") + let bottomTitle = paid ? String(localized: "Spent amount:") + : String(localized: "Amount to spend:") + if let effective { // payment possible + let fee = computeFee(raw: raw, eff: effective) + ThreeAmountsSection(stack: stack.push("PaymentView2"), + scope: firstScope, + topTitle: topTitle, + topAbbrev: topAbbrev, + topAmount: raw, + noFees: nil, // TODO: check baseURL for fees + fee: fee, + feeIsNegative: nil, + bottomTitle: bottomTitle, + bottomAbbrev: String(localized: "Effective:", comment: "mini"), + bottomAmount: effective, + large: false, + pendingDialog: !paid, + isDone: paid, + incoming: false, + baseURL: baseURL, + txStateLcl: nil, + summary: nil, // summary already shown in PaymentView above choices + products: products) + // TODO: payment: popup with all possible exchanges, check fees + } else if let balanceDetails { // Insufficient + if let localizedCause = balanceDetails.causeHint?.localizedCause(raw.currencyStr) { + Text(localizedCause) + .talerFont(.headline) + } + ThreeAmountsSection(stack: stack.push(), + scope: firstScope, + topTitle: topTitle, + topAbbrev: topAbbrev, + topAmount: raw, + noFees: nil, // TODO: check baseURL for fees + fee: nil, + feeIsNegative: nil, + bottomTitle: String(localized: "Amount available:"), + bottomAbbrev: String(localized: "Available:", comment: "mini"), + bottomAmount: balanceDetails.balanceAvailable, + large: false, + pendingDialog: false, // TODO: true to always show currency the payment will be made in? + isDone: false, + incoming: false, + baseURL: baseURL, + txStateLcl: nil, + summary: nil, // summary already shown in PaymentView above choices + products: products) + } else { + // TODO: Error - neither effective nor balanceDetails + Text("Error") + .talerFont(.body) + } + } +} +// MARK: - +struct PaySafeArea: View, Sendable { + let symLog: SymLogV? + let stack: CallStack + let terms: MerchantContractTerms + let amountString: String + let amountA11y: String + @Binding var payNow: Bool + @State private var wasTapped: Bool = false + + func timeToPay(_ terms: MerchantContractTerms) -> Int { + if let milliseconds = try? terms.payDeadline.milliseconds() { + let date = Date(milliseconds: milliseconds) + let now = Date.now + let timeInterval = now.timeIntervalSince(date) + if timeInterval < 0 { + symLog?.log("\(timeInterval) seconds left to pay") + return Int(-timeInterval) + } else { + symLog?.log("\(date) - \(now) = \(timeInterval)") + } + } else { + symLog?.log("no milliseconds") + } + return 0 + } + + var body: some View { + let timeToPay = timeToPay(terms) + let showTime = timeToPay > 0 && timeToPay < 300 + let button = Button("Pay \(amountString) now") { + if !wasTapped { + wasTapped = true + symLog?.log("paying \(amountString) now") + payNow = true + } + } + .accessibilityLabel(Text("Pay \(amountA11y) now", comment: "a11y")) + .buttonStyle(TalerButtonStyle(type: .prominent)) + .disabled(wasTapped) + .padding(.horizontal) + + if showTime { + let view = VStack { + TimeView(String(localized: "Time to pay:"), + seconds: timeToPay) + .padding(.top) + button + .padding(.bottom, 4) + } + if #available(iOS 26.0, *) { + view + .glassEffect(in: .rect(cornerRadius: 16.0)) + .padding(.horizontal) + } else { + view + } + } else { + let _ = symLog?.log("\(timeToPay) not shown") + button + } + } +} +// MARK: - +#if false +struct PaymentURIView_Previews: PreviewProvider { + static var previews: some View { + let merchant = Merchant(name: "Merchant") + let extra = Extra(articleName: "articleName") + let product = Product(description: "description") + let terms = MerchantContractTerms(hWire: "hWire", + wireMethod: "wireMethod", + summary: "summary", + summaryI18n: nil, + nonce: "nonce", + amount: Amount(currency: LONGCURRENCY, cent: 220), + payDeadline: Timestamp.tomorrow(), + maxFee: Amount(currency: LONGCURRENCY, cent: 20), + merchant: merchant, + merchantPub: "merchantPub", + deliveryDate: nil, + deliveryLocation: nil, + exchanges: [], + products: [product], + refundDeadline: Timestamp.tomorrow(), + wireTransferDeadline: Timestamp.tomorrow(), + timestamp: Timestamp.now(), + orderID: "orderID", + merchantBaseURL: "merchantBaseURL", + fulfillmentURL: "fulfillmentURL", + publicReorderURL: "publicReorderURL", + fulfillmentMessage: nil, + fulfillmentMessageI18n: nil, + minimumAge: nil +// extra: extra, +// auditors: [] + ) + let details = PreparePayResult(status: PreparePayResultType.paymentPossible, + transactionId: "txn:payment:012345", + contractTerms: terms, + contractTermsHash: "termsHash", + amountRaw: Amount(currency: LONGCURRENCY, cent: 220), + amountEffective: Amount(currency: LONGCURRENCY, cent: 240), + balanceDetails: nil, + paid: nil +// , talerUri: "talerURI" + ) + let url = URL(string: "taler://pay/some_amount")! + +// @State private var amount: Amount? = nil // templateParam +// @State private var summary: String? = nil // templateParam + + PaymentView(stack: CallStack("Preview"), url: url, + template: false, amountToTransfer: nil, summary: nil, + amountIsEditable: false, summaryIsEditable: false, + preparePayResult: details) + } +} +#endif diff --git a/TalerWallet1/Views/Sheets/Payment/PaymentView.swift b/TalerWallet1/Views/Sheets/Payment/PaymentView.swift @@ -1,326 +0,0 @@ -/* - * This file is part of GNU Taler, ©2022-26 Taler Systems S.A. - * See LICENSE.md - */ -/** - * @author Marc Stibane - */ -import SwiftUI -import taler_swift -import SymLog - -typealias Announce = (_ this: String) -> () - -fileprivate func feeLabel(_ feeString: String) -> String { - feeString.isEmpty ? EMPTYSTRING : String(localized: "+ \(feeString) fee") -} - -// MARK: - -// Will be called either by the user scanning a <pay> QR code or tapping the provided link, -// both from the shop's website - or even from a printed QR code. -// We show the payment details in a sheet, and a "Confirm payment" / "Pay now" button. -// This is also the final view after the user entered data of a <pay-template>. -struct PaymentView: View, Sendable { - private let symLog = SymLogV(0) - let stack: CallStack - - // the scanned URL - let url: URL - let template: Bool - @Binding var amountToTransfer: Amount - @Binding var summary: String - let amountIsEditable: Bool // - let summaryIsEditable: Bool // - - @EnvironmentObject private var model: WalletModel - @EnvironmentObject private var controller: Controller - @AppStorage("myListStyle") var myListStyle: MyListStyle = .automatic - - @State private var currencyInfo: CurrencyInfo = CurrencyInfo.zero(UNKNOWN) - @State var txId: String? = nil - - @State private var elapsed: Int = 0 - @State private var talerTX = TalerTransaction(dummyCurrency: DEMOCURRENCY) - - @MainActor - private func viewDidLoad() async { -// symLog.log(".task") - if template { - if let templateResponse = try? await model.preparePayForTemplate(url.trimmedString, - amount: amountIsEditable ? amountToTransfer : nil, - summary: summaryIsEditable ? summary : nil) { - txId = templateResponse.transactionId -// preparePayResult = templateResponse -// let raw = templateResponse.amountRaw -// controller.updateAmount(raw, forSaved: url) - } - } else { - if let payResponse = try? await model.preparePayForUri(url.trimmedString) { - txId = payResponse.transactionId -// let raw = payResponse.amountRaw -// controller.updateAmount(raw, forSaved: url) // TODO: update scanned URL - } - } - } - - var body: some View { - ZStack { - if let txId { - TransactionSummaryList(stack: stack.push(), - transactionId: txId, - talerTX: $talerTX, - navTitle: nil, - hasDone: true, // conclude payment - showDone: .prominent, - url: url, - withActions: false) -#if TALER_NIGHTLY2 -// if let preparePayResult { - let status = preparePayResult.status - let paid = status == .alreadyConfirmed - let navTitle = paid ? String(localized: "Already paid", comment:"pay merchant navTitle") - : String(localized: "Confirm Payment", comment:"pay merchant navTitle") - let list = List { - TransactionSummaryList.MerchantHeader(terms: terms) - - if paid { - Text("You already paid for this article.") - .talerFont(.headline) - if let fulfillmentUrl = terms.fulfillmentURL { - if let destination = URL(string: fulfillmentUrl) { - let buttonTitle = terms.fulfillmentMessage ?? String(localized: "Open merchant website") - Link(buttonTitle, destination: destination) - .buttonStyle(TalerButtonStyle(type: .bordered)) - .accessibilityHint(String(localized: "Will go to the merchant website.", comment: "a11y")) - } - } - } // You already paid - - } - .listStyle(myListStyle.style).anyView -#if OIM - .overlay { if #available(iOS 16.4, *) { - if controller.oimSheetActive { - OIMpayView(stack: stack.push(), - amount: effective) - } - } } -#endif - - if #available(iOS 17.0, *) { - list.toolbarTitleDisplayMode(.inlineLarge) - } else { - list - } -#endif - } else { - LoadingView(stack: stack.push(), scopeInfo: nil, message: url.host) - .task { await viewDidLoad() } - } - }.onAppear() { - symLog.log("onAppear") - DebugViewC.shared.setSheetID(SHEET_PAYMENT, stack: stack.push()) - } - } -} -// MARK: - -// MARK: - -struct PaymentView2: View, Sendable { - let stack: CallStack - let paid: Bool - let raw: Amount - let effective: Amount? - let firstScope: ScopeInfo? - let baseURL: String? -// let terms: MerchantContractTerms - let summary: String? - let products: [Product]? - let balanceDetails: PaymentInsufficientBalanceDetails? - - func computeFee(raw: Amount?, eff: Amount?) -> Amount? { - if let raw, let eff { - return try! Amount.diff(raw, eff) // TODO: different currencies - } - return nil - } - - var body: some View { - // TODO: show balanceDetails.balanceAvailable - let topTitle = paid ? String(localized: "Paid amount:") - : String(localized: "Amount to pay:") - let topAbbrev = paid ? String(localized: "Paid:", comment: "mini") - : String(localized: "Pay:", comment: "mini") - let bottomTitle = paid ? String(localized: "Spent amount:") - : String(localized: "Amount to spend:") - if let effective { // payment possible - let fee = computeFee(raw: raw, eff: effective) - ThreeAmountsSection(stack: stack.push("PaymentView2"), - scope: firstScope, - topTitle: topTitle, - topAbbrev: topAbbrev, - topAmount: raw, - noFees: nil, // TODO: check baseURL for fees - fee: fee, - feeIsNegative: nil, - bottomTitle: bottomTitle, - bottomAbbrev: String(localized: "Effective:", comment: "mini"), - bottomAmount: effective, - large: false, - pendingDialog: !paid, - isDone: paid, - incoming: false, - baseURL: baseURL, - txStateLcl: nil, - summary: nil, // summary already shown in PaymentView above choices - products: products) - // TODO: payment: popup with all possible exchanges, check fees - } else if let balanceDetails { // Insufficient - if let localizedCause = balanceDetails.causeHint?.localizedCause(raw.currencyStr) { - Text(localizedCause) - .talerFont(.headline) - } - ThreeAmountsSection(stack: stack.push(), - scope: firstScope, - topTitle: topTitle, - topAbbrev: topAbbrev, - topAmount: raw, - noFees: nil, // TODO: check baseURL for fees - fee: nil, - feeIsNegative: nil, - bottomTitle: String(localized: "Amount available:"), - bottomAbbrev: String(localized: "Available:", comment: "mini"), - bottomAmount: balanceDetails.balanceAvailable, - large: false, - pendingDialog: false, // TODO: true to always show currency the payment will be made in? - isDone: false, - incoming: false, - baseURL: baseURL, - txStateLcl: nil, - summary: nil, // summary already shown in PaymentView above choices - products: products) - } else { - // TODO: Error - neither effective nor balanceDetails - Text("Error") - .talerFont(.body) - } - } -} -// MARK: - -struct PaySafeArea: View, Sendable { - let symLog: SymLogV? - let stack: CallStack - let terms: MerchantContractTerms - let amountString: String - let amountA11y: String - @Binding var payNow: Bool - @State private var wasTapped: Bool = false - - func timeToPay(_ terms: MerchantContractTerms) -> Int { - if let milliseconds = try? terms.payDeadline.milliseconds() { - let date = Date(milliseconds: milliseconds) - let now = Date.now - let timeInterval = now.timeIntervalSince(date) - if timeInterval < 0 { - symLog?.log("\(timeInterval) seconds left to pay") - return Int(-timeInterval) - } else { - symLog?.log("\(date) - \(now) = \(timeInterval)") - } - } else { - symLog?.log("no milliseconds") - } - return 0 - } - - var body: some View { - let timeToPay = timeToPay(terms) - let showTime = timeToPay > 0 && timeToPay < 300 - let button = Button("Pay \(amountString) now") { - if !wasTapped { - wasTapped = true - symLog?.log("paying \(amountString) now") - payNow = true - } - } - .accessibilityLabel(Text("Pay \(amountA11y) now", comment: "a11y")) - .buttonStyle(TalerButtonStyle(type: .prominent)) - .disabled(wasTapped) - .padding(.horizontal) - - if showTime { - let view = VStack { - TimeView(String(localized: "Time to pay:"), - seconds: timeToPay) - .padding(.top) - button - .padding(.bottom, 4) - } - if #available(iOS 26.0, *) { - view - .glassEffect(in: .rect(cornerRadius: 16.0)) - .padding(.horizontal) - } else { - view - } - } else { - let _ = symLog?.log("\(timeToPay) not shown") - button - } - } -} -// MARK: - -#if false -struct PaymentURIView_Previews: PreviewProvider { - static var previews: some View { - let merchant = Merchant(name: "Merchant") - let extra = Extra(articleName: "articleName") - let product = Product(description: "description") - let terms = MerchantContractTerms(hWire: "hWire", - wireMethod: "wireMethod", - summary: "summary", - summaryI18n: nil, - nonce: "nonce", - amount: Amount(currency: LONGCURRENCY, cent: 220), - payDeadline: Timestamp.tomorrow(), - maxFee: Amount(currency: LONGCURRENCY, cent: 20), - merchant: merchant, - merchantPub: "merchantPub", - deliveryDate: nil, - deliveryLocation: nil, - exchanges: [], - products: [product], - refundDeadline: Timestamp.tomorrow(), - wireTransferDeadline: Timestamp.tomorrow(), - timestamp: Timestamp.now(), - orderID: "orderID", - merchantBaseURL: "merchantBaseURL", - fulfillmentURL: "fulfillmentURL", - publicReorderURL: "publicReorderURL", - fulfillmentMessage: nil, - fulfillmentMessageI18n: nil, - minimumAge: nil -// extra: extra, -// auditors: [] - ) - let details = PreparePayResult(status: PreparePayResultType.paymentPossible, - transactionId: "txn:payment:012345", - contractTerms: terms, - contractTermsHash: "termsHash", - amountRaw: Amount(currency: LONGCURRENCY, cent: 220), - amountEffective: Amount(currency: LONGCURRENCY, cent: 240), - balanceDetails: nil, - paid: nil -// , talerUri: "talerURI" - ) - let url = URL(string: "taler://pay/some_amount")! - -// @State private var amount: Amount? = nil // templateParam -// @State private var summary: String? = nil // templateParam - - PaymentView(stack: CallStack("Preview"), url: url, - template: false, amountToTransfer: nil, summary: nil, - amountIsEditable: false, summaryIsEditable: false, - preparePayResult: details) - } -} -#endif diff --git a/TalerWallet1/Views/Sheets/URLSheet.swift b/TalerWallet1/Views/Sheets/URLSheet.swift @@ -70,7 +70,7 @@ struct URLSheet: View { selectedBalance: selectedBalance, url: passedURL) case .pay: - PaymentView(stack: stack.push(), url: passedURL, + PaymentScan(stack: stack.push(), url: passedURL, template: false, amountToTransfer: $amountToTransfer, summary: $summary, amountIsEditable: false, summaryIsEditable: false) case .payPull: