WithdrawURIView.swift (11586B)
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 WithdrawURIView: 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 if let details = try? await model.getWithdrawalDetailsForAmount(amount, 77 baseUrl: exchange.exchangeBaseUrl, 78 scope: nil) { // TODO: scope 79 let fee = try? details.amountRaw - details.amountEffective 80 let feeStr = fee?.formatted(currencyInfo, isNegative: true) ?? ("0", "0") 81 symLog.log("Fee = \(feeStr.0)") 82 let insufficient = if let amountAvailable { 83 (try? details.amountRaw < amountAvailable) ?? true 84 } else { 85 false 86 } 87 withdrawalDetails = details 88 return ComputeFeeResult(insufficient: insufficient, feeAmount: fee, 89 feeStr: (feeLabel(feeStr.0), feeLabel(feeStr.1)), 90 numCoins: details.numCoins) 91 } else { 92 // TODO: don't call this again 93 } 94 } 95 } else { 96 symLog.log("No exchange!") 97 } 98 return nil 99 } 100 101 @MainActor 102 private func viewDidLoad() async { 103 symLog.log(".task") 104 do { 105 let response = try await model.prepareBankIntegratedWithdrawal(url.absoluteString) 106 let txId = response.transactionId 107 let uriInfoResponse = response.info 108 let amount = uriInfoResponse.amount 109 let currency = amount?.currencyStr ?? uriInfoResponse.currency 110 amountToTransfer = amount ?? Amount.zero(currency: currency) 111 amountIsEditable = uriInfoResponse.editableAmount 112 amountAvailable = uriInfoResponse.maxAmount // may be nil 113 if let available = amountAvailable { 114 amountZero = available 115 } 116 wireFee = uriInfoResponse.wireFee // may be nil 117 defaultExchangeBaseUrl = uriInfoResponse.defaultExchangeBaseUrl 118 possibleExchanges = uriInfoResponse.possibleExchanges 119 let baseUrl = defaultExchangeBaseUrl 120 ?? possibleExchanges.first?.exchangeBaseUrl 121 ?? nil 122 123 // await loadExchange(baseUrl) <- checkCurrencyInfo now returns the exchange 124 // symLog.log("\(baseUrl.trimURL) loaded") 125 126 if let baseUrl, let someExchange = try? await controller.checkCurrencyInfo(for: baseUrl, model: model) { 127 symLog.log("Info(for: \(baseUrl.trimURL) loaded") 128 exchange = someExchange 129 } else { 130 exchangeError = true 131 } 132 } catch { 133 // TODO: error, dismiss 134 } 135 } 136 137 var body: some View { 138 #if PRINT_CHANGES 139 let _ = Self._printChanges() 140 let _ = symLog.vlog(url.absoluteString) // just to get the # to compare it with .onAppear & onDisappear 141 #endif 142 if let exchange2 = exchange { 143 if let defaultBaseUrl = defaultExchangeBaseUrl ?? possibleExchanges.first?.exchangeBaseUrl { 144 VStack { 145 let title = String(localized: "using:", comment: "using: exchange.taler.net") 146 if possibleExchanges.count > 1 { 147 Picker(title, selection: $selectedExchange) { 148 ForEach(possibleExchanges, id: \.self) { exchange in 149 let baseUrl = exchange.exchangeBaseUrl 150 Text(baseUrl) 151 } 152 } 153 .talerFont(.title3) 154 .pickerStyle(.menu) 155 .onAppear() { selectedExchange = defaultBaseUrl } 156 .onChange(of: selectedExchange) { selected in 157 Task { await loadExchange(selected) } 158 } 159 } // load defaultBaseUrl 160 let acceptDestination = WithdrawAcceptView(stack: stack.push(), 161 url: url, 162 scope: exchange2.scopeInfo, 163 amountToTransfer: $amountToTransfer, 164 wireFee: $wireFee, 165 exchange: $exchange) // from possibleExchanges 166 if amountIsEditable { 167 ScrollView { 168 let shortcutDest = WithdrawAcceptView(stack: stack.push(), 169 url: url, 170 scope: exchange2.scopeInfo, 171 amountToTransfer: $amountShortcut, 172 wireFee: $wireFee, 173 exchange: $exchange) // from possibleExchanges 174 let actions = Group { 175 NavLink($shortcutSelected) { shortcutDest } 176 NavLink($buttonSelected) { acceptDestination } 177 } 178 let a11yLabel = String(localized: "Amount to withdraw:", comment: "a11y, no abbreviations") 179 let amountLabel = minimalistic ? String(localized: "Amount:") // maxAmount 180 : a11yLabel 181 // TODO: input amount, then 182 AmountInputV(stack: stack.push(), 183 scope: exchange2.scopeInfo, 184 amountAvailable: $amountZero, 185 amountLabel: amountZero.isZero ? amountLabel : nil, 186 a11yLabel: a11yLabel, 187 amountToTransfer: $amountToTransfer, 188 amountLastUsed: amountLastUsed, 189 wireFee: wireFee, 190 summary: $summary, 191 shortcutAction: shortcutAction, 192 buttonAction: buttonAction, 193 isIncoming: true, 194 computeFee: computeFeeWithdraw) 195 .background(actions) 196 } // ScrollView 197 // .ignoresSafeArea(.keyboard, edges: .bottom) 198 } else { 199 acceptDestination 200 } // directly show the accept view 201 } 202 .navigationTitle(navTitle) 203 .task(id: controller.currencyTicker) { 204 currencyInfo = controller.info(for: exchange2.scopeInfo, controller.currencyTicker) 205 } 206 .onAppear() { 207 symLog.log("onAppear") 208 DebugViewC.shared.setSheetID(SHEET_WITHDRAWAL, stack: stack.push()) 209 } 210 // agePicker.setAges(ages: details?.ageRestrictionOptions) 211 } 212 } else if exchangeError { 213 let possibleExchangeBaseUrl: String? = possibleExchanges.first?.exchangeBaseUrl 214 let message = defaultExchangeBaseUrl != nil ? "defaultExchangeBaseUrl: \(defaultExchangeBaseUrl)" 215 : possibleExchanges.first != nil ? String(localized: "No default - first possible payment service: \(possibleExchangeBaseUrl ?? EMPTYSTRING)") 216 : String(localized: "No default, no possible payment services") 217 ErrorView(stack.push(), 218 title: String(localized: "Error loading payment service details:"), 219 message: message, 220 copyable: true 221 ) { 222 dismissTop(stack.push()) 223 } 224 } else { // no details or no exchange 225 #if DEBUG 226 let message = url.host 227 #else 228 let message: String? = nil 229 #endif 230 LoadingView(stack: stack.push(), scopeInfo: nil, message: message) 231 .task { await viewDidLoad() } 232 } 233 } 234 }