ManualWithdraw.swift (13944B)
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 when tapping [Withdraw] 13 // or from WithdrawExchangeScan after a withdraw-exchange QR was scanned 14 struct ManualWithdraw: View { 15 private let symLog = SymLogV(0) 16 let stack: CallStack 17 let url: URL? 18 // when Action is tapped while in currency TransactionList… 19 let selectedBalance: Balance? // …then use THIS balance, otherwise show picker 20 @Binding var amountLastUsed: Amount 21 @Binding var amountToTransfer: Amount // Update currency when used 22 let exchange: Exchange? // only for withdraw-exchange 23 let maySwitchCurrencies: Bool // not for withdraw-exchange 24 let isSheet: Bool // true: WithdrawExchangeScan 25 26 @EnvironmentObject private var controller: Controller 27 @EnvironmentObject private var model: WalletModel 28 29 @State private var balanceIndex = 0 30 @State private var balance: Balance? = nil // nil only when balances == [] 31 @State private var currencyInfo: CurrencyInfo = CurrencyInfo.zero(UNKNOWN) 32 33 private func viewDidLoad() async { 34 if let exchange { 35 currencyInfo = controller.info(for: exchange.scopeInfo, controller.currencyTicker) 36 return 37 } else if let selectedBalance { 38 balance = selectedBalance 39 balanceIndex = controller.balances.firstIndex(of: selectedBalance) ?? 0 40 } else { 41 balanceIndex = 0 42 balance = controller.balances.isEmpty ? nil : controller.balances[0] 43 } 44 if let balance { 45 currencyInfo = controller.info(for: balance.scopeInfo, controller.currencyTicker) 46 } 47 } 48 49 func navTitle(_ currency: String, _ condition: Bool = false) -> String { 50 condition ? String(localized: "NavTitle_Withdraw_Currency)", 51 defaultValue: "Withdraw \(currency)", 52 comment: "NavTitle: Withdraw 'currency'") 53 : String(localized: "NavTitle_Withdraw", 54 defaultValue: "Withdraw", 55 comment: "NavTitle: Withdraw") 56 } 57 58 func updateCurrInfo(_ scope: ScopeInfo) { 59 symLog.log("balance = \(scope.url)") 60 amountToTransfer.setCurrency(scope.currency) 61 currencyInfo = controller.info(for: scope, controller.currencyTicker) 62 } 63 64 var body: some View { 65 #if PRINT_CHANGES 66 let _ = Self._printChanges() 67 #endif 68 let currencySymbol = currencyInfo.symbol 69 let navA11y = navTitle(currencyInfo.name) // always include currency for a11y 70 let navTitle = navTitle(currencySymbol, currencyInfo.hasSymbol) 71 let count = controller.balances.count 72 let scrollView = ScrollView { 73 if maySwitchCurrencies && count > 1 { 74 ScopePicker(stack: stack.push(), 75 value: $balanceIndex, 76 onlyNonZero: false) 77 { index in 78 balanceIndex = index 79 balance = controller.balances[index] 80 if let balance { 81 updateCurrInfo(balance.scopeInfo) 82 } 83 } 84 .padding(.horizontal) 85 .padding(.bottom, 4) 86 } // TODO: else show static text? 87 if let scope = balance?.scopeInfo ?? exchange?.scopeInfo { 88 let _ = symLog.log("exchange = \(exchange?.exchangeBaseUrl), scope = \(scope.url), amountToTransfer = \(amountToTransfer.currencyStr)") 89 ManualWithdrawContent(stack: stack.push(), 90 url: url, 91 scope: scope, 92 amountLastUsed: $amountLastUsed, 93 amountToTransfer: $amountToTransfer, 94 exchange: exchange) 95 } else { // should never happen, we either have an exchange or a balance 96 ErrorView(stack.push(), title: "ManualWithdraw", 97 message: "ManualWithdrawContent: Cannot determine scope", copyable: true) 98 } 99 } // ScrollView 100 .navigationTitle(navTitle) 101 .frame(maxWidth: .infinity, alignment: .leading) 102 .background(FullBackground()) 103 .onAppear { 104 if isSheet { 105 DebugViewC.shared.setSheetID(SHEET_WITHDRAW_EXCHANGE, // 135 WithdrawExchangeScan 106 stack: stack.push()) 107 } else { 108 DebugViewC.shared.setViewID(VIEW_WITHDRAWAL, // 30 WithdrawAmount 109 stack: stack.push()) 110 } 111 symLog.log("❗️ \(navTitle) onAppear") 112 } 113 .onDisappear { 114 symLog.log("❗️ \(navTitle) onDisappear") 115 } 116 .task { await viewDidLoad() } 117 .task(id: controller.currencyTicker) { 118 // runs whenever a new currencyInfo is available 119 symLog.log("❗️ task \(controller.currencyTicker)") 120 let scopeInfo = maySwitchCurrencies ? balance?.scopeInfo 121 : exchange?.scopeInfo 122 if let scopeInfo { 123 updateCurrInfo(scopeInfo) 124 } 125 } 126 127 if #available(iOS 16.4, *) { 128 scrollView.toolbar(.hidden, for: .tabBar) 129 .scrollBounceBehavior(.basedOnSize) 130 } else { 131 scrollView 132 } 133 } 134 } 135 // MARK: - 136 struct ManualWithdrawContent: View { 137 private let symLog = SymLogV(0) 138 let stack: CallStack 139 let url: URL? 140 let scope: ScopeInfo 141 @Binding var amountLastUsed: Amount 142 @Binding var amountToTransfer: Amount 143 let exchange: Exchange? 144 145 @EnvironmentObject private var controller: Controller 146 @EnvironmentObject private var model: WalletModel 147 @AppStorage("minimalistic") var minimalistic: Bool = false 148 #if DEBUG 149 @AppStorage("developerMode") var developerMode: Bool = true 150 #else 151 @AppStorage("developerMode") var developerMode: Bool = false 152 #endif 153 154 @State private var detailsForAmount: WithdrawalDetailsForAmount? = nil 155 @State private var loadError: Error? = nil 156 // @State var ageMenuList: [Int] = [] 157 // @State var selectedAge = 0 158 @State private var tosAccepted = false 159 160 @MainActor 161 private func reloadExchange(_ baseURL: String) async { 162 symLog.log("getExchangeByUrl(\(baseURL))") 163 let exchange = try? await model.getExchangeByUrl(url: baseURL) 164 if let tosStatus = exchange?.tosStatus { 165 tosAccepted = (tosStatus == .accepted) 166 || (tosStatus == .missingTos) 167 } else { 168 tosAccepted = false 169 } 170 } 171 172 @MainActor 173 private func getWithdrawalDetailsForAmount(_ amount: Amount, _ reload: Bool = false) async { 174 do { 175 let details = try await model.getWithdrawalDetailsForAmount(amount, 176 baseUrl: nil, 177 scope: scope, 178 viewHandles: true) 179 if reload { 180 await reloadExchange(details.exchangeBaseUrl) 181 } 182 detailsForAmount = details 183 loadError = nil 184 // agePicker.setAges(ages: detailsForAmount?.ageRestrictionOptions) 185 } catch WalletBackendError.walletCoreError(let walletBackendResponseError) { 186 symLog.log(walletBackendResponseError?.hint) 187 // TODO: ignore WALLET_CORE_REQUEST_CANCELLED but handle all others 188 // Passing non-nil to clientCancellationId will throw WALLET_CORE_REQUEST_CANCELLED 189 // when calling getWithdrawalDetailsForAmount again before the last call returned. 190 // Since amountToTransfer changed and we don't need the old fee anymore, we just 191 // ignore it and do nothing. 192 // especially DON'T set detailsForAmount to nil 193 if detailsForAmount == nil, 194 walletBackendResponseError?.code != 7036 { // WALLET_CORE_REQUEST_CANCELLED 195 // nothing to show yet, so we would spin forever - show the error instead 196 loadError = WalletBackendError.walletCoreError(walletBackendResponseError) 197 } 198 } catch { 199 symLog.log(error.localizedDescription) 200 detailsForAmount = nil 201 loadError = error 202 } 203 } 204 205 @MainActor 206 private func computeFee(_ amount: Amount) async -> ComputeFeeResult? { 207 if amount.isZero { 208 return ComputeFeeResult.zero() 209 } 210 await getWithdrawalDetailsForAmount(amount) 211 // TODO: actually compute the fee 212 return nil 213 } // computeFee 214 215 @MainActor 216 private func viewDidLoad2() async { 217 // neues scope wenn balance geändert wird? 218 await getWithdrawalDetailsForAmount(amountToTransfer, true) 219 } 220 221 private func withdrawButtonTitle(_ currency: String) -> String { 222 switch currency { 223 case CHF_4217: 224 String(localized: "WITHDRAW_CONFIRM_BUTTONTITLE_CHF", defaultValue: "Confirm Withdrawal") 225 case EUR_4217: 226 String(localized: "WITHDRAW_CONFIRM_BUTTONTITLE_EUR", defaultValue: "Confirm Withdrawal") 227 default: 228 String(localized: "WITHDRAW_CONFIRM_BUTTONTITLE", defaultValue: "Confirm Withdrawal") 229 } 230 } 231 232 var body: some View { 233 #if PRINT_CHANGES 234 let _ = Self._printChanges() 235 let _ = symLog.vlog() // just to get the # to compare it with .onAppear & onDisappear 236 #endif 237 238 if let detailsForAmount { 239 ScrollView { 240 let coinData = CoinData(details: detailsForAmount) 241 let currency = detailsForAmount.scopeInfo.currency 242 let baseURL = detailsForAmount.exchangeBaseUrl 243 // let agePicker = AgePicker(ageMenuList: $ageMenuList, selectedAge: $selectedAge) 244 // let restrictAge: Int? = (selectedAge == 0) ? nil 245 // : selectedAge 246 // let _ = print(selectedAge, restrictAge) 247 let destination = ManualWithdrawDone(stack: stack.push(), 248 // scope: detailsForAmount.scopeInfo, 249 url: url, 250 baseURL: baseURL, 251 amountToTransfer: amountToTransfer) 252 // restrictAge: restrictAge) 253 let disabled = amountToTransfer.isZero || coinData.invalid || coinData.tooMany 254 255 if tosAccepted { // Von der Bank abzuhebender Betrag 256 let a11yLabel = String(localized: "Amount to withdraw from bank", comment: "a11y, no abbreviations") 257 let title = String(localized: "Amount to withdraw from bank:") 258 let amountLabel = minimalistic ? String(localized: "Amount:") 259 : title 260 CurrencyInputView(scope: scope, 261 amount: $amountToTransfer, 262 amountLastUsed: amountLastUsed, 263 available: nil, // enable shortcuts always 264 title: amountLabel, 265 a11yTitle: a11yLabel, 266 shortcutAction: nil) 267 .padding(.top) 268 .task(id: amountToTransfer.value) { // re-run this whenever amountToTransfer changes 269 await computeFee(amountToTransfer) 270 } 271 QuiteSomeCoins(scope: scope, 272 coinData: coinData, 273 shouldShowFee: true, // TODO: set to false if we never charge withdrawal fees 274 feeIsNegative: true) 275 // agePicker 276 NavigationLink(destination: destination) { // VIEW_WITHDRAW_ACCEPT 277 Text(withdrawButtonTitle(currency)) 278 } 279 .buttonStyle(TalerButtonStyle(type: .prominent, disabled: disabled)) 280 .disabled(disabled) 281 .padding(.top) 282 } else { 283 ToSButtonView(stack: stack.push(), 284 exchangeBaseUrl: scope.url ?? baseURL, 285 viewID: VIEW_WITHDRAW_TOS, // 31 WithdrawTOSView TODO: might be withdraw-exchange 286 p2p: false, 287 acceptAction: nil) 288 .padding(.top) 289 } 290 } // ScrollView 291 .padding(.horizontal) 292 // .ignoresSafeArea(.keyboard, edges: .bottom) 293 .task(id: amountToTransfer.currencyStr) { 294 await getWithdrawalDetailsForAmount(amountToTransfer, true) 295 } 296 } else if let loadError { 297 // "OK" clears the error, which brings back the LoadingView and thus retries 298 ErrorView(stack.push(), error: loadError, devMode: developerMode) { 299 self.loadError = nil 300 } 301 } else { 302 LoadingView(stack: stack.push(), scopeInfo: scope, message: nil) 303 .task { await viewDidLoad2() } 304 } 305 } 306 }