WithdrawUriScan.swift (11925B)
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 // Called either when scanning a QR code or tapping the provided link, both from the bank's website. 13 // We show the user the bank-integrated withdrawal details in a sheet - but first the ToS must be accepted. 14 // After the user confirmed the withdrawal, we show a button to return to the bank website to authorize (2FA) 15 struct WithdrawUriScan: View { 16 private let symLog = SymLogV(0) 17 let stack: CallStack 18 19 // the URL from the bank website 20 let url: URL 21 22 @EnvironmentObject private var controller: Controller 23 @EnvironmentObject private var model: WalletModel 24 @AppStorage("minimalistic") var minimalistic: Bool = false 25 @AppStorage("myListStyle") var myListStyle: MyListStyle = .automatic 26 27 @State private var withdrawUriInfo: WithdrawUriInfoResponse? = nil 28 @State private var amountIsEditable = false 29 @State private var amountToTransfer = Amount.zero(currency: EMPTYSTRING) // Update currency when used 30 @State private var amountShortcut = Amount.zero(currency: EMPTYSTRING) // Update currency when used 31 @State private var amountLastUsed = Amount.zero(currency: EMPTYSTRING) // Update currency when used 32 @State private var amountZero = Amount.zero(currency: EMPTYSTRING) // needed for isZero 33 @State private var amountLastChecked = Amount.zero(currency: EMPTYSTRING) // 34 @State private var buttonSelected = false 35 @State private var shortcutSelected = false 36 @State private var amountAvailable: Amount? = nil 37 @State private var wireFee: Amount? = nil 38 @State private var summary = EMPTYSTRING 39 40 @State private var selectedExchange = EMPTYSTRING 41 @State private var exchange: Exchange? = nil 42 @State private var exchangeError: Bool = false 43 @State private var possibleExchanges: [Exchange] = [] 44 @State private var defaultExchangeBaseUrl: String? // if nil then use possibleExchanges 45 @State private var currencyInfo: CurrencyInfo = CurrencyInfo.zero(UNKNOWN) 46 47 // @State private var feeAmount: Amount? = nil 48 @State private var withdrawalDetails: WithdrawalDetailsForAmount? = nil 49 50 let navTitle = String(localized: "Withdrawal") 51 52 @MainActor 53 func loadExchange(_ baseUrl: String) async { // TODO: throws? 54 if let someExchange = try? await model.getExchangeByUrl(url: baseUrl) { 55 symLog.log("Loaded \(baseUrl.trimURL)") 56 exchange = someExchange 57 } 58 } 59 60 private func shortcutAction(_ shortcut: Amount) { 61 amountShortcut = shortcut 62 shortcutSelected = true 63 } 64 private func buttonAction() { buttonSelected = true } 65 66 private func feeLabel(_ feeString: String) -> String { 67 feeString.isEmpty ? EMPTYSTRING : String(localized: "\(feeString) fee") 68 } 69 70 @MainActor 71 private func computeFeeWithdraw(_ amount: Amount) async -> ComputeFeeResult? { 72 if let exchange { 73 if amountLastChecked != amount { 74 amountLastChecked = amount 75 76 // viewHandles: the next digit's call has the same progressToken (baseUrl), so wallet-core 77 // cancels this one with WALLET_CORE_REQUEST_CANCELLED - not an error to show the user 78 if let details = try? await model.getWithdrawalDetailsForAmount(amount, 79 baseUrl: exchange.exchangeBaseUrl, 80 scope: nil, // TODO: scope 81 viewHandles: true) { 82 let fee = try? details.amountRaw - details.amountEffective 83 let feeStr = fee?.formatted(currencyInfo, isNegative: true) ?? ("0", "0") 84 symLog.log("Fee = \(feeStr.0)") 85 // amountAvailable is the bank's maxAmount: only more than that is too much 86 let insufficient = if let amountAvailable { 87 (try? details.amountRaw > amountAvailable) ?? true 88 } else { 89 false 90 } 91 withdrawalDetails = details 92 return ComputeFeeResult(insufficient: insufficient, feeAmount: fee, 93 feeStr: (feeLabel(feeStr.0), feeLabel(feeStr.1)), 94 numCoins: details.numCoins) 95 } else { 96 // TODO: don't call this again 97 } 98 } 99 } else { 100 symLog.log("No exchange!") 101 } 102 return nil 103 } 104 105 @MainActor 106 private func viewDidLoad() async { 107 symLog.log(".task") 108 do { 109 let response = try await model.prepareBankIntegratedWithdrawal(url.trimmedString) 110 let txId = response.transactionId 111 let uriInfoResponse = response.info 112 let amount = uriInfoResponse.amount 113 let currency = amount?.currencyStr ?? uriInfoResponse.currency 114 amountToTransfer = amount ?? Amount.zero(currency: currency) 115 amountIsEditable = uriInfoResponse.editableAmount 116 amountAvailable = uriInfoResponse.maxAmount // may be nil 117 if let available = amountAvailable { 118 amountZero = available 119 } 120 wireFee = uriInfoResponse.wireFee // may be nil 121 defaultExchangeBaseUrl = uriInfoResponse.defaultExchangeBaseUrl 122 possibleExchanges = uriInfoResponse.possibleExchanges 123 let baseUrl = defaultExchangeBaseUrl 124 ?? possibleExchanges.first?.exchangeBaseUrl 125 ?? nil 126 127 // await loadExchange(baseUrl) <- checkCurrencyInfo now returns the exchange 128 // symLog.log("\(baseUrl.trimURL) loaded") 129 130 if let baseUrl, let someExchange = try? await controller.checkCurrencyInfo(for: baseUrl) { 131 symLog.log("Info(for: \(baseUrl.trimURL) loaded") 132 exchange = someExchange 133 } else { 134 exchangeError = true 135 } 136 } catch { 137 // TODO: error, dismiss 138 } 139 } 140 141 var body: some View { 142 #if PRINT_CHANGES 143 let _ = Self._printChanges() 144 let _ = symLog.vlog(url.absoluteString) // just to get the # to compare it with .onAppear & onDisappear 145 #endif 146 if let exchange2 = exchange { 147 if let defaultBaseUrl = defaultExchangeBaseUrl ?? possibleExchanges.first?.exchangeBaseUrl { 148 VStack { 149 let title = String(localized: "using:", comment: "using: exchange.taler.net") 150 if possibleExchanges.count > 1 { 151 Picker(title, selection: $selectedExchange) { 152 ForEach(possibleExchanges, id: \.self) { exchange in 153 let baseUrl = exchange.exchangeBaseUrl 154 Text(baseUrl) 155 } 156 } 157 .talerFont(.title3) 158 .pickerStyle(.menu) 159 .onAppear() { selectedExchange = defaultBaseUrl } 160 .onChange(of: selectedExchange) { selected in 161 Task { await loadExchange(selected) } 162 } 163 } // load defaultBaseUrl 164 let acceptDestination = WithdrawAcceptView(stack: stack.push(), 165 url: url, 166 scope: exchange2.scopeInfo, 167 amountToTransfer: $amountToTransfer, 168 wireFee: $wireFee, 169 exchange: $exchange) // from possibleExchanges 170 if amountIsEditable { 171 ScrollView { 172 let shortcutDest = WithdrawAcceptView(stack: stack.push(), 173 url: url, 174 scope: exchange2.scopeInfo, 175 amountToTransfer: $amountShortcut, 176 wireFee: $wireFee, 177 exchange: $exchange) // from possibleExchanges 178 let actions = Group { 179 NavLink($shortcutSelected) { shortcutDest } 180 NavLink($buttonSelected) { acceptDestination } 181 } 182 let a11yLabel = String(localized: "Amount to withdraw:", comment: "a11y, no abbreviations") 183 let amountLabel = minimalistic ? String(localized: "Amount:") // maxAmount 184 : a11yLabel 185 // TODO: input amount, then 186 AmountInputV(stack: stack.push(), 187 scope: exchange2.scopeInfo, 188 amountAvailable: $amountZero, 189 amountLabel: amountZero.isZero ? amountLabel : nil, 190 a11yLabel: a11yLabel, 191 amountToTransfer: $amountToTransfer, 192 amountLastUsed: amountLastUsed, 193 wireFee: wireFee, 194 summary: $summary, 195 shortcutAction: shortcutAction, 196 buttonAction: buttonAction, 197 isIncoming: true, 198 computeFee: computeFeeWithdraw) 199 .background(actions) 200 } // ScrollView 201 // .ignoresSafeArea(.keyboard, edges: .bottom) 202 } else { 203 acceptDestination 204 } // directly show the accept view 205 } 206 .navigationTitle(navTitle) 207 .task(id: controller.currencyTicker) { 208 currencyInfo = controller.info(for: exchange2.scopeInfo, controller.currencyTicker) 209 } 210 .onAppear() { 211 symLog.log("onAppear") 212 DebugViewC.shared.setSheetID(SHEET_WITHDRAWAL, stack: stack.push()) 213 } 214 // agePicker.setAges(ages: details?.ageRestrictionOptions) 215 } 216 } else if exchangeError { 217 let possibleExchangeBaseUrl: String? = possibleExchanges.first?.exchangeBaseUrl 218 let message = defaultExchangeBaseUrl != nil ? String(localized: "Payment service \(defaultExchangeBaseUrl ?? EMPTYSTRING) could not be loaded.") 219 : possibleExchanges.first != nil ? String(localized: "No default - first possible payment service: \(possibleExchangeBaseUrl ?? EMPTYSTRING)") 220 : String(localized: "No default, no possible payment services") 221 ErrorView(stack.push(), 222 title: String(localized: "Error loading payment service details:"), 223 message: message, 224 copyable: true) 225 } else { // no details or no exchange 226 #if DEBUG 227 let message = url.host 228 #else 229 let message: String? = nil 230 #endif 231 LoadingView(stack: stack.push(), scopeInfo: nil, message: message) 232 .task { await viewDidLoad() } 233 } 234 } 235 }