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