WithdrawURIView.swift (10499B)
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 possibleExchanges: [Exchange] = [] 43 @State private var defaultExchangeBaseUrl: String? // if nil then use possibleExchanges 44 @State private var currencyInfo: CurrencyInfo = CurrencyInfo.zero(UNKNOWN) 45 46 // @State private var feeAmount: Amount? = nil 47 @State private var withdrawalDetails: WithdrawalDetailsForAmount? = nil 48 49 let navTitle = String(localized: "Withdrawal") 50 51 @MainActor 52 func loadExchange(_ baseUrl: String) async { // TODO: throws? 53 if let someExchange = try? await model.getExchangeByUrl(url: baseUrl) { 54 symLog.log("Loaded \(baseUrl.trimURL)") 55 exchange = someExchange 56 } 57 } 58 59 private func shortcutAction(_ shortcut: Amount) { 60 amountShortcut = shortcut 61 shortcutSelected = true 62 } 63 private func buttonAction() { buttonSelected = true } 64 65 private func feeLabel(_ feeString: String) -> String { 66 feeString.count > 0 ? String(localized: "\(feeString) fee") 67 : EMPTYSTRING 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 uriInfoResponse = try await model.getWithdrawalDetailsForUri(url.absoluteString) 106 let amount = uriInfoResponse.amount 107 let currency = amount?.currencyStr ?? uriInfoResponse.currency 108 amountToTransfer = amount ?? Amount.zero(currency: currency) 109 amountIsEditable = uriInfoResponse.editableAmount 110 amountAvailable = uriInfoResponse.maxAmount // may be nil 111 if let available = amountAvailable { 112 amountZero = available 113 } 114 wireFee = uriInfoResponse.wireFee // may be nil 115 let baseUrl = uriInfoResponse.defaultExchangeBaseUrl 116 ?? uriInfoResponse.possibleExchanges[0].exchangeBaseUrl 117 defaultExchangeBaseUrl = uriInfoResponse.defaultExchangeBaseUrl 118 possibleExchanges = uriInfoResponse.possibleExchanges 119 120 // await loadExchange(baseUrl) <- checkCurrencyInfo now returns the exchange 121 // symLog.log("\(baseUrl.trimURL) loaded") 122 123 if let someExchange = try? await controller.checkCurrencyInfo(for: baseUrl, model: model) { 124 symLog.log("Info(for: \(baseUrl.trimURL) loaded") 125 exchange = someExchange 126 } 127 } catch { 128 // TODO: error, dismiss 129 } 130 } 131 132 var body: some View { 133 #if PRINT_CHANGES 134 let _ = Self._printChanges() 135 let _ = symLog.vlog(url.absoluteString) // just to get the # to compare it with .onAppear & onDisappear 136 #endif 137 if let exchange2 = exchange { 138 if let defaultBaseUrl = defaultExchangeBaseUrl ?? possibleExchanges.first?.exchangeBaseUrl { 139 VStack { 140 let title = String(localized: "using:", comment: "using: exchange.taler.net") 141 if possibleExchanges.count > 1 { 142 Picker(title, selection: $selectedExchange) { 143 ForEach(possibleExchanges, id: \.self) { exchange in 144 let baseUrl = exchange.exchangeBaseUrl 145 Text(baseUrl) 146 } 147 } 148 .talerFont(.title3) 149 .pickerStyle(.menu) 150 .onAppear() { selectedExchange = defaultBaseUrl } 151 .onChange(of: selectedExchange) { selected in 152 Task { await loadExchange(selected) } 153 } 154 } // load defaultBaseUrl 155 let acceptDestination = WithdrawAcceptView(stack: stack.push(), 156 url: url, 157 scope: exchange2.scopeInfo, 158 amountToTransfer: $amountToTransfer, 159 wireFee: $wireFee, 160 exchange: $exchange) // from possibleExchanges 161 if amountIsEditable { 162 ScrollView { 163 let shortcutDest = WithdrawAcceptView(stack: stack.push(), 164 url: url, 165 scope: exchange2.scopeInfo, 166 amountToTransfer: $amountShortcut, 167 wireFee: $wireFee, 168 exchange: $exchange) // from possibleExchanges 169 let actions = Group { 170 NavLink($shortcutSelected) { shortcutDest } 171 NavLink($buttonSelected) { acceptDestination } 172 } 173 let a11yLabel = String(localized: "Amount to withdraw:", comment: "a11y, no abbreviations") 174 let amountLabel = minimalistic ? String(localized: "Amount:") // maxAmount 175 : a11yLabel 176 // TODO: input amount, then 177 AmountInputV(stack: stack.push(), 178 scope: exchange2.scopeInfo, 179 amountAvailable: $amountZero, 180 amountLabel: amountZero.isZero ? amountLabel : nil, 181 a11yLabel: a11yLabel, 182 amountToTransfer: $amountToTransfer, 183 amountLastUsed: amountLastUsed, 184 wireFee: wireFee, 185 summary: $summary, 186 shortcutAction: shortcutAction, 187 buttonAction: buttonAction, 188 isIncoming: true, 189 computeFee: computeFeeWithdraw) 190 .background(actions) 191 } // ScrollView 192 } else { 193 acceptDestination 194 } // directly show the accept view 195 } 196 .navigationTitle(navTitle) 197 .task(id: controller.currencyTicker) { 198 currencyInfo = controller.info(for: exchange2.scopeInfo, controller.currencyTicker) 199 } 200 .onAppear() { 201 symLog.log("onAppear") 202 DebugViewC.shared.setSheetID(SHEET_WITHDRAWAL) 203 } 204 // agePicker.setAges(ages: details?.ageRestrictionOptions) 205 } 206 207 208 } else { // no details or no exchange 209 #if DEBUG 210 let message = url.host 211 #else 212 let message: String? = nil 213 #endif 214 LoadingView(stack: stack.push(), scopeInfo: nil, message: message) 215 .task { await viewDidLoad() } 216 } 217 } 218 }