commit 883f4b0237e6269587a4e982aede5b03e83d5ed0
parent 9a431e9b0103d968e6019f3521b26ae1896c1fb4
Author: Marc Stibane <marc@taler.net>
Date: Fri, 7 Aug 2026 08:11:55 +0200
WithdrawUriScan
Diffstat:
4 files changed, 236 insertions(+), 236 deletions(-)
diff --git a/TalerWallet1/Controllers/DebugViewC.swift b/TalerWallet1/Controllers/DebugViewC.swift
@@ -79,7 +79,7 @@ public let VIEW_SETTINGS3 = VIEW_SETTINGS2 + 1 // 76 DebugS
// MARK: - Bank-Integrated Withdrawal
// openURL (Link or scan QR) ==> obtains coins from bank
-public let SHEET_WITHDRAWAL = VIEW_WITHDRAWAL + 100 // 130 WithdrawURIView
+public let SHEET_WITHDRAWAL = VIEW_WITHDRAWAL + 100 // 130 WithdrawUriScan
public let SHEET_WITHDRAW_TOS = SHEET_WITHDRAWAL + 1 // 131 WithdrawTOSView
public let SHEET_WITHDRAW_ACCEPT = SHEET_WITHDRAW_TOS + 1 // 132 WithdrawAcceptView
public let SHEET_WITHDRAW_AUTHORIZE = SHEET_WITHDRAW_ACCEPT + 1 // 133 waiting for bank confirmation
diff --git a/TalerWallet1/Views/Sheets/URLSheet.swift b/TalerWallet1/Views/Sheets/URLSheet.swift
@@ -64,7 +64,7 @@ struct URLSheet: View {
case .addExchange: // TODO: just check the ToS
ExchangeListView(stack: stack.push(), url: $passedURL)
case .withdraw:
- WithdrawURIView(stack: stack.push(), url: passedURL)
+ WithdrawUriScan(stack: stack.push(), url: passedURL)
case .withdrawExchange:
WithdrawExchangeScan(stack: stack.push(),
selectedBalance: selectedBalance,
diff --git a/TalerWallet1/Views/Sheets/WithdrawBankIntegrated/WithdrawURIView.swift b/TalerWallet1/Views/Sheets/WithdrawBankIntegrated/WithdrawURIView.swift
@@ -1,234 +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
-
-// Called either when scanning a QR code or tapping the provided link, both from the bank's website.
-// We show the user the bank-integrated withdrawal details in a sheet - but first the ToS must be accepted.
-// After the user confirmed the withdrawal, we show a button to return to the bank website to authorize (2FA)
-struct WithdrawURIView: View {
- private let symLog = SymLogV(0)
- let stack: CallStack
-
- // the URL from the bank website
- let url: URL
-
- @EnvironmentObject private var controller: Controller
- @EnvironmentObject private var model: WalletModel
- @AppStorage("minimalistic") var minimalistic: Bool = false
- @AppStorage("myListStyle") var myListStyle: MyListStyle = .automatic
-
- @State private var withdrawUriInfo: WithdrawUriInfoResponse? = nil
- @State private var amountIsEditable = false
- @State private var amountToTransfer = Amount.zero(currency: EMPTYSTRING) // Update currency when used
- @State private var amountShortcut = Amount.zero(currency: EMPTYSTRING) // Update currency when used
- @State private var amountLastUsed = Amount.zero(currency: EMPTYSTRING) // Update currency when used
- @State private var amountZero = Amount.zero(currency: EMPTYSTRING) // needed for isZero
- @State private var amountLastChecked = Amount.zero(currency: EMPTYSTRING) //
- @State private var buttonSelected = false
- @State private var shortcutSelected = false
- @State private var amountAvailable: Amount? = nil
- @State private var wireFee: Amount? = nil
- @State private var summary = EMPTYSTRING
-
- @State private var selectedExchange = EMPTYSTRING
- @State private var exchange: Exchange? = nil
- @State private var exchangeError: Bool = false
- @State private var possibleExchanges: [Exchange] = []
- @State private var defaultExchangeBaseUrl: String? // if nil then use possibleExchanges
- @State private var currencyInfo: CurrencyInfo = CurrencyInfo.zero(UNKNOWN)
-
-// @State private var feeAmount: Amount? = nil
- @State private var withdrawalDetails: WithdrawalDetailsForAmount? = nil
-
- let navTitle = String(localized: "Withdrawal")
-
- @MainActor
- func loadExchange(_ baseUrl: String) async { // TODO: throws?
- if let someExchange = try? await model.getExchangeByUrl(url: baseUrl) {
- symLog.log("Loaded \(baseUrl.trimURL)")
- exchange = someExchange
- }
- }
-
- private func shortcutAction(_ shortcut: Amount) {
- amountShortcut = shortcut
- shortcutSelected = true
- }
- private func buttonAction() { buttonSelected = true }
-
- private func feeLabel(_ feeString: String) -> String {
- feeString.isEmpty ? EMPTYSTRING : String(localized: "\(feeString) fee")
- }
-
- @MainActor
- private func computeFeeWithdraw(_ amount: Amount) async -> ComputeFeeResult? {
- if let exchange {
- if amountLastChecked != amount {
- amountLastChecked = amount
-
- if let details = try? await model.getWithdrawalDetailsForAmount(amount,
- baseUrl: exchange.exchangeBaseUrl,
- scope: nil) { // TODO: scope
- let fee = try? details.amountRaw - details.amountEffective
- let feeStr = fee?.formatted(currencyInfo, isNegative: true) ?? ("0", "0")
- symLog.log("Fee = \(feeStr.0)")
- let insufficient = if let amountAvailable {
- (try? details.amountRaw < amountAvailable) ?? true
- } else {
- false
- }
- withdrawalDetails = details
- return ComputeFeeResult(insufficient: insufficient, feeAmount: fee,
- feeStr: (feeLabel(feeStr.0), feeLabel(feeStr.1)),
- numCoins: details.numCoins)
- } else {
- // TODO: don't call this again
- }
- }
- } else {
- symLog.log("No exchange!")
- }
- return nil
- }
-
- @MainActor
- private func viewDidLoad() async {
- symLog.log(".task")
- do {
- let response = try await model.prepareBankIntegratedWithdrawal(url.trimmedString)
- let txId = response.transactionId
- let uriInfoResponse = response.info
- let amount = uriInfoResponse.amount
- let currency = amount?.currencyStr ?? uriInfoResponse.currency
- amountToTransfer = amount ?? Amount.zero(currency: currency)
- amountIsEditable = uriInfoResponse.editableAmount
- amountAvailable = uriInfoResponse.maxAmount // may be nil
- if let available = amountAvailable {
- amountZero = available
- }
- wireFee = uriInfoResponse.wireFee // may be nil
- defaultExchangeBaseUrl = uriInfoResponse.defaultExchangeBaseUrl
- possibleExchanges = uriInfoResponse.possibleExchanges
- let baseUrl = defaultExchangeBaseUrl
- ?? possibleExchanges.first?.exchangeBaseUrl
- ?? nil
-
-// await loadExchange(baseUrl) <- checkCurrencyInfo now returns the exchange
-// symLog.log("\(baseUrl.trimURL) loaded")
-
- if let baseUrl, let someExchange = try? await controller.checkCurrencyInfo(for: baseUrl, model: model) {
- symLog.log("Info(for: \(baseUrl.trimURL) loaded")
- exchange = someExchange
- } else {
- exchangeError = true
- }
- } catch {
- // TODO: error, dismiss
- }
- }
-
- var body: some View {
-#if PRINT_CHANGES
- let _ = Self._printChanges()
- let _ = symLog.vlog(url.absoluteString) // just to get the # to compare it with .onAppear & onDisappear
-#endif
- if let exchange2 = exchange {
- if let defaultBaseUrl = defaultExchangeBaseUrl ?? possibleExchanges.first?.exchangeBaseUrl {
- VStack {
- let title = String(localized: "using:", comment: "using: exchange.taler.net")
- if possibleExchanges.count > 1 {
- Picker(title, selection: $selectedExchange) {
- ForEach(possibleExchanges, id: \.self) { exchange in
- let baseUrl = exchange.exchangeBaseUrl
- Text(baseUrl)
- }
- }
- .talerFont(.title3)
- .pickerStyle(.menu)
- .onAppear() { selectedExchange = defaultBaseUrl }
- .onChange(of: selectedExchange) { selected in
- Task { await loadExchange(selected) }
- }
- } // load defaultBaseUrl
- let acceptDestination = WithdrawAcceptView(stack: stack.push(),
- url: url,
- scope: exchange2.scopeInfo,
- amountToTransfer: $amountToTransfer,
- wireFee: $wireFee,
- exchange: $exchange) // from possibleExchanges
- if amountIsEditable {
- ScrollView {
- let shortcutDest = WithdrawAcceptView(stack: stack.push(),
- url: url,
- scope: exchange2.scopeInfo,
- amountToTransfer: $amountShortcut,
- wireFee: $wireFee,
- exchange: $exchange) // from possibleExchanges
- let actions = Group {
- NavLink($shortcutSelected) { shortcutDest }
- NavLink($buttonSelected) { acceptDestination }
- }
- let a11yLabel = String(localized: "Amount to withdraw:", comment: "a11y, no abbreviations")
- let amountLabel = minimalistic ? String(localized: "Amount:") // maxAmount
- : a11yLabel
- // TODO: input amount, then
- AmountInputV(stack: stack.push(),
- scope: exchange2.scopeInfo,
- amountAvailable: $amountZero,
- amountLabel: amountZero.isZero ? amountLabel : nil,
- a11yLabel: a11yLabel,
- amountToTransfer: $amountToTransfer,
- amountLastUsed: amountLastUsed,
- wireFee: wireFee,
- summary: $summary,
- shortcutAction: shortcutAction,
- buttonAction: buttonAction,
- isIncoming: true,
- computeFee: computeFeeWithdraw)
- .background(actions)
- } // ScrollView
-// .ignoresSafeArea(.keyboard, edges: .bottom)
- } else {
- acceptDestination
- } // directly show the accept view
- }
- .navigationTitle(navTitle)
- .task(id: controller.currencyTicker) {
- currencyInfo = controller.info(for: exchange2.scopeInfo, controller.currencyTicker)
- }
- .onAppear() {
- symLog.log("onAppear")
- DebugViewC.shared.setSheetID(SHEET_WITHDRAWAL, stack: stack.push())
- }
-// agePicker.setAges(ages: details?.ageRestrictionOptions)
- }
- } else if exchangeError {
- let possibleExchangeBaseUrl: String? = possibleExchanges.first?.exchangeBaseUrl
- let message = defaultExchangeBaseUrl != nil ? "defaultExchangeBaseUrl: \(defaultExchangeBaseUrl)"
- : possibleExchanges.first != nil ? String(localized: "No default - first possible payment service: \(possibleExchangeBaseUrl ?? EMPTYSTRING)")
- : String(localized: "No default, no possible payment services")
- ErrorView(stack.push(),
- title: String(localized: "Error loading payment service details:"),
- message: message,
- copyable: true
- ) {
- dismissTop(stack.push())
- }
- } else { // no details or no exchange
-#if DEBUG
- let message = url.host
-#else
- let message: String? = nil
-#endif
- LoadingView(stack: stack.push(), scopeInfo: nil, message: message)
- .task { await viewDidLoad() }
- }
- }
-}
diff --git a/TalerWallet1/Views/Sheets/WithdrawBankIntegrated/WithdrawUriScan.swift b/TalerWallet1/Views/Sheets/WithdrawBankIntegrated/WithdrawUriScan.swift
@@ -0,0 +1,234 @@
+/*
+ * 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
+
+// Called either when scanning a QR code or tapping the provided link, both from the bank's website.
+// We show the user the bank-integrated withdrawal details in a sheet - but first the ToS must be accepted.
+// After the user confirmed the withdrawal, we show a button to return to the bank website to authorize (2FA)
+struct WithdrawUriScan: View {
+ private let symLog = SymLogV(0)
+ let stack: CallStack
+
+ // the URL from the bank website
+ let url: URL
+
+ @EnvironmentObject private var controller: Controller
+ @EnvironmentObject private var model: WalletModel
+ @AppStorage("minimalistic") var minimalistic: Bool = false
+ @AppStorage("myListStyle") var myListStyle: MyListStyle = .automatic
+
+ @State private var withdrawUriInfo: WithdrawUriInfoResponse? = nil
+ @State private var amountIsEditable = false
+ @State private var amountToTransfer = Amount.zero(currency: EMPTYSTRING) // Update currency when used
+ @State private var amountShortcut = Amount.zero(currency: EMPTYSTRING) // Update currency when used
+ @State private var amountLastUsed = Amount.zero(currency: EMPTYSTRING) // Update currency when used
+ @State private var amountZero = Amount.zero(currency: EMPTYSTRING) // needed for isZero
+ @State private var amountLastChecked = Amount.zero(currency: EMPTYSTRING) //
+ @State private var buttonSelected = false
+ @State private var shortcutSelected = false
+ @State private var amountAvailable: Amount? = nil
+ @State private var wireFee: Amount? = nil
+ @State private var summary = EMPTYSTRING
+
+ @State private var selectedExchange = EMPTYSTRING
+ @State private var exchange: Exchange? = nil
+ @State private var exchangeError: Bool = false
+ @State private var possibleExchanges: [Exchange] = []
+ @State private var defaultExchangeBaseUrl: String? // if nil then use possibleExchanges
+ @State private var currencyInfo: CurrencyInfo = CurrencyInfo.zero(UNKNOWN)
+
+// @State private var feeAmount: Amount? = nil
+ @State private var withdrawalDetails: WithdrawalDetailsForAmount? = nil
+
+ let navTitle = String(localized: "Withdrawal")
+
+ @MainActor
+ func loadExchange(_ baseUrl: String) async { // TODO: throws?
+ if let someExchange = try? await model.getExchangeByUrl(url: baseUrl) {
+ symLog.log("Loaded \(baseUrl.trimURL)")
+ exchange = someExchange
+ }
+ }
+
+ private func shortcutAction(_ shortcut: Amount) {
+ amountShortcut = shortcut
+ shortcutSelected = true
+ }
+ private func buttonAction() { buttonSelected = true }
+
+ private func feeLabel(_ feeString: String) -> String {
+ feeString.isEmpty ? EMPTYSTRING : String(localized: "\(feeString) fee")
+ }
+
+ @MainActor
+ private func computeFeeWithdraw(_ amount: Amount) async -> ComputeFeeResult? {
+ if let exchange {
+ if amountLastChecked != amount {
+ amountLastChecked = amount
+
+ if let details = try? await model.getWithdrawalDetailsForAmount(amount,
+ baseUrl: exchange.exchangeBaseUrl,
+ scope: nil) { // TODO: scope
+ let fee = try? details.amountRaw - details.amountEffective
+ let feeStr = fee?.formatted(currencyInfo, isNegative: true) ?? ("0", "0")
+ symLog.log("Fee = \(feeStr.0)")
+ let insufficient = if let amountAvailable {
+ (try? details.amountRaw < amountAvailable) ?? true
+ } else {
+ false
+ }
+ withdrawalDetails = details
+ return ComputeFeeResult(insufficient: insufficient, feeAmount: fee,
+ feeStr: (feeLabel(feeStr.0), feeLabel(feeStr.1)),
+ numCoins: details.numCoins)
+ } else {
+ // TODO: don't call this again
+ }
+ }
+ } else {
+ symLog.log("No exchange!")
+ }
+ return nil
+ }
+
+ @MainActor
+ private func viewDidLoad() async {
+ symLog.log(".task")
+ do {
+ let response = try await model.prepareBankIntegratedWithdrawal(url.trimmedString)
+ let txId = response.transactionId
+ let uriInfoResponse = response.info
+ let amount = uriInfoResponse.amount
+ let currency = amount?.currencyStr ?? uriInfoResponse.currency
+ amountToTransfer = amount ?? Amount.zero(currency: currency)
+ amountIsEditable = uriInfoResponse.editableAmount
+ amountAvailable = uriInfoResponse.maxAmount // may be nil
+ if let available = amountAvailable {
+ amountZero = available
+ }
+ wireFee = uriInfoResponse.wireFee // may be nil
+ defaultExchangeBaseUrl = uriInfoResponse.defaultExchangeBaseUrl
+ possibleExchanges = uriInfoResponse.possibleExchanges
+ let baseUrl = defaultExchangeBaseUrl
+ ?? possibleExchanges.first?.exchangeBaseUrl
+ ?? nil
+
+// await loadExchange(baseUrl) <- checkCurrencyInfo now returns the exchange
+// symLog.log("\(baseUrl.trimURL) loaded")
+
+ if let baseUrl, let someExchange = try? await controller.checkCurrencyInfo(for: baseUrl, model: model) {
+ symLog.log("Info(for: \(baseUrl.trimURL) loaded")
+ exchange = someExchange
+ } else {
+ exchangeError = true
+ }
+ } catch {
+ // TODO: error, dismiss
+ }
+ }
+
+ var body: some View {
+#if PRINT_CHANGES
+ let _ = Self._printChanges()
+ let _ = symLog.vlog(url.absoluteString) // just to get the # to compare it with .onAppear & onDisappear
+#endif
+ if let exchange2 = exchange {
+ if let defaultBaseUrl = defaultExchangeBaseUrl ?? possibleExchanges.first?.exchangeBaseUrl {
+ VStack {
+ let title = String(localized: "using:", comment: "using: exchange.taler.net")
+ if possibleExchanges.count > 1 {
+ Picker(title, selection: $selectedExchange) {
+ ForEach(possibleExchanges, id: \.self) { exchange in
+ let baseUrl = exchange.exchangeBaseUrl
+ Text(baseUrl)
+ }
+ }
+ .talerFont(.title3)
+ .pickerStyle(.menu)
+ .onAppear() { selectedExchange = defaultBaseUrl }
+ .onChange(of: selectedExchange) { selected in
+ Task { await loadExchange(selected) }
+ }
+ } // load defaultBaseUrl
+ let acceptDestination = WithdrawAcceptView(stack: stack.push(),
+ url: url,
+ scope: exchange2.scopeInfo,
+ amountToTransfer: $amountToTransfer,
+ wireFee: $wireFee,
+ exchange: $exchange) // from possibleExchanges
+ if amountIsEditable {
+ ScrollView {
+ let shortcutDest = WithdrawAcceptView(stack: stack.push(),
+ url: url,
+ scope: exchange2.scopeInfo,
+ amountToTransfer: $amountShortcut,
+ wireFee: $wireFee,
+ exchange: $exchange) // from possibleExchanges
+ let actions = Group {
+ NavLink($shortcutSelected) { shortcutDest }
+ NavLink($buttonSelected) { acceptDestination }
+ }
+ let a11yLabel = String(localized: "Amount to withdraw:", comment: "a11y, no abbreviations")
+ let amountLabel = minimalistic ? String(localized: "Amount:") // maxAmount
+ : a11yLabel
+ // TODO: input amount, then
+ AmountInputV(stack: stack.push(),
+ scope: exchange2.scopeInfo,
+ amountAvailable: $amountZero,
+ amountLabel: amountZero.isZero ? amountLabel : nil,
+ a11yLabel: a11yLabel,
+ amountToTransfer: $amountToTransfer,
+ amountLastUsed: amountLastUsed,
+ wireFee: wireFee,
+ summary: $summary,
+ shortcutAction: shortcutAction,
+ buttonAction: buttonAction,
+ isIncoming: true,
+ computeFee: computeFeeWithdraw)
+ .background(actions)
+ } // ScrollView
+// .ignoresSafeArea(.keyboard, edges: .bottom)
+ } else {
+ acceptDestination
+ } // directly show the accept view
+ }
+ .navigationTitle(navTitle)
+ .task(id: controller.currencyTicker) {
+ currencyInfo = controller.info(for: exchange2.scopeInfo, controller.currencyTicker)
+ }
+ .onAppear() {
+ symLog.log("onAppear")
+ DebugViewC.shared.setSheetID(SHEET_WITHDRAWAL, stack: stack.push())
+ }
+// agePicker.setAges(ages: details?.ageRestrictionOptions)
+ }
+ } else if exchangeError {
+ let possibleExchangeBaseUrl: String? = possibleExchanges.first?.exchangeBaseUrl
+ let message = defaultExchangeBaseUrl != nil ? "defaultExchangeBaseUrl: \(defaultExchangeBaseUrl)"
+ : possibleExchanges.first != nil ? String(localized: "No default - first possible payment service: \(possibleExchangeBaseUrl ?? EMPTYSTRING)")
+ : String(localized: "No default, no possible payment services")
+ ErrorView(stack.push(),
+ title: String(localized: "Error loading payment service details:"),
+ message: message,
+ copyable: true
+ ) {
+ dismissTop(stack.push())
+ }
+ } else { // no details or no exchange
+#if DEBUG
+ let message = url.host
+#else
+ let message: String? = nil
+#endif
+ LoadingView(stack: stack.push(), scopeInfo: nil, message: message)
+ .task { await viewDidLoad() }
+ }
+ }
+}