ManualWithdraw.swift (14417B)
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 WithdrawExchangeV 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 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 let title = "ManualWithdrawContent: Cannot determine scope" // should not happen, so no L10N 97 ErrorView(stack.push("ManualWithdraw"), title: title, message: nil, copyable: true) { 98 dismissTop(stack.push()) 99 } 100 } 101 } // ScrollView 102 .navigationTitle(navTitle) 103 .frame(maxWidth: .infinity, alignment: .leading) 104 .background(FullBackground()) 105 .onAppear { 106 if isSheet { 107 DebugViewC.shared.setSheetID(SHEET_WITHDRAW_ACCEPT, // 132 WithdrawAcceptView 108 stack: stack.push()) 109 } else { 110 DebugViewC.shared.setViewID(VIEW_WITHDRAWAL, // 30 WithdrawAmount 111 stack: stack.push()) 112 } 113 symLog.log("❗️ \(navTitle) onAppear") 114 } 115 .onDisappear { 116 symLog.log("❗️ \(navTitle) onDisappear") 117 } 118 .task { await viewDidLoad() } 119 .task(id: controller.currencyTicker) { 120 // runs whenever a new currencyInfo is available 121 symLog.log("❗️ task \(controller.currencyTicker)") 122 let scopeInfo = maySwitchCurrencies ? balance?.scopeInfo 123 : exchange?.scopeInfo 124 if let scopeInfo { 125 updateCurrInfo(scopeInfo) 126 } 127 } 128 129 if #available(iOS 16.4, *) { 130 scrollView.toolbar(.hidden, for: .tabBar) 131 .scrollBounceBehavior(.basedOnSize) 132 } else { 133 scrollView 134 } 135 } 136 } 137 // MARK: - 138 struct ManualWithdrawContent: View { 139 private let symLog = SymLogV(0) 140 let stack: CallStack 141 let url: URL? 142 let scope: ScopeInfo 143 @Binding var amountLastUsed: Amount 144 @Binding var amountToTransfer: Amount 145 let exchange: Exchange? 146 147 @EnvironmentObject private var controller: Controller 148 @EnvironmentObject private var model: WalletModel 149 @AppStorage("minimalistic") var minimalistic: Bool = false 150 151 @State private var detailsForAmount: WithdrawalDetailsForAmount? = nil 152 // @State var ageMenuList: [Int] = [] 153 // @State var selectedAge = 0 154 @State private var tosAccepted = false 155 156 @MainActor 157 private func reloadExchange(_ baseURL: String) async { 158 symLog.log("getExchangeByUrl(\(baseURL))") 159 let exchange = try? await model.getExchangeByUrl(url: baseURL) 160 if let tosStatus = exchange?.tosStatus { 161 tosAccepted = (tosStatus == .accepted) 162 || (tosStatus == .missingTos) 163 } else { 164 tosAccepted = false 165 } 166 } 167 168 @MainActor 169 private func getWithdrawalDetailsForAmount(_ amount: Amount, _ reload: Bool = false) async { 170 do { 171 let details = try await model.getWithdrawalDetailsForAmount(amount, 172 baseUrl: nil, 173 scope: scope, 174 viewHandles: true) 175 if reload { 176 await reloadExchange(details.exchangeBaseUrl) 177 } 178 detailsForAmount = details 179 // agePicker.setAges(ages: detailsForAmount?.ageRestrictionOptions) 180 } catch WalletBackendError.walletCoreError(let walletBackendResponseError) { 181 symLog.log(walletBackendResponseError?.hint) 182 // TODO: ignore WALLET_CORE_REQUEST_CANCELLED but handle all others 183 // Passing non-nil to clientCancellationId will throw WALLET_CORE_REQUEST_CANCELLED 184 // when calling getWithdrawalDetailsForAmount again before the last call returned. 185 // Since amountToTransfer changed and we don't need the old fee anymore, we just 186 // ignore it and do nothing. 187 // especially DON'T set detailsForAmount to nil 188 } catch { 189 symLog.log(error.localizedDescription) 190 detailsForAmount = nil 191 } 192 } 193 194 @MainActor 195 private func computeFee(_ amount: Amount) async -> ComputeFeeResult? { 196 if amount.isZero { 197 return ComputeFeeResult.zero() 198 } 199 await getWithdrawalDetailsForAmount(amount) 200 // TODO: actually compute the fee 201 return nil 202 } // computeFee 203 204 @MainActor 205 private func viewDidLoad2() async { 206 // neues scope wenn balance geändert wird? 207 await getWithdrawalDetailsForAmount(amountToTransfer, true) 208 } 209 210 private func withdrawButtonTitle(_ currency: String) -> String { 211 switch currency { 212 case CHF_4217: 213 String(localized: "WITHDRAW_CONFIRM_BUTTONTITLE_CHF", defaultValue: "Confirm Withdrawal") 214 case EUR_4217: 215 String(localized: "WITHDRAW_CONFIRM_BUTTONTITLE_EUR", defaultValue: "Confirm Withdrawal") 216 default: 217 String(localized: "WITHDRAW_CONFIRM_BUTTONTITLE", defaultValue: "Confirm Withdrawal") 218 } 219 } 220 221 var body: some View { 222 #if PRINT_CHANGES 223 let _ = Self._printChanges() 224 let _ = symLog.vlog() // just to get the # to compare it with .onAppear & onDisappear 225 #endif 226 227 if let detailsForAmount { 228 ScrollView { 229 let coinData = CoinData(details: detailsForAmount) 230 let currency = detailsForAmount.scopeInfo.currency 231 let baseURL = detailsForAmount.exchangeBaseUrl 232 // let agePicker = AgePicker(ageMenuList: $ageMenuList, selectedAge: $selectedAge) 233 // let restrictAge: Int? = (selectedAge == 0) ? nil 234 // : selectedAge 235 // let _ = print(selectedAge, restrictAge) 236 let destination = ManualWithdrawDone(stack: stack.push(), 237 // scope: detailsForAmount.scopeInfo, 238 url: url, 239 baseURL: baseURL, 240 amountToTransfer: amountToTransfer) 241 // restrictAge: restrictAge) 242 let disabled = amountToTransfer.isZero || coinData.invalid || coinData.tooMany 243 244 if tosAccepted { // Von der Bank abzuhebender Betrag 245 let a11yLabel = String(localized: "Amount to withdraw from bank", comment: "a11y, no abbreviations") 246 let title = String(localized: "Amount to withdraw from bank:") 247 let amountLabel = minimalistic ? String(localized: "Amount:") 248 : title 249 CurrencyInputView(scope: scope, 250 amount: $amountToTransfer, 251 amountLastUsed: amountLastUsed, 252 available: nil, // enable shortcuts always 253 title: amountLabel, 254 a11yTitle: a11yLabel, 255 shortcutAction: nil) 256 .padding(.top) 257 .task(id: amountToTransfer.value) { // re-run this whenever amountToTransfer changes 258 await computeFee(amountToTransfer) 259 } 260 QuiteSomeCoins(scope: scope, 261 coinData: coinData, 262 shouldShowFee: true, // TODO: set to false if we never charge withdrawal fees 263 feeIsNegative: true) 264 // agePicker 265 NavigationLink(destination: destination) { // VIEW_WITHDRAW_ACCEPT 266 Text(withdrawButtonTitle(currency)) 267 } 268 .buttonStyle(TalerButtonStyle(type: .prominent, disabled: disabled)) 269 .disabled(disabled) 270 .padding(.top) 271 } else { 272 ToSButtonView(stack: stack.push(), 273 exchangeBaseUrl: scope.url ?? baseURL, 274 viewID: VIEW_WITHDRAW_TOS, // 31 WithdrawTOSView TODO: might be withdraw-exchange 275 p2p: false, 276 acceptAction: nil) 277 .padding(.top) 278 } 279 } // ScrollView 280 .padding(.horizontal) 281 // .ignoresSafeArea(.keyboard, edges: .bottom) 282 .task(id: amountToTransfer.currencyStr) { 283 await getWithdrawalDetailsForAmount(amountToTransfer, true) 284 } 285 } else { 286 LoadingView(stack: stack.push(), scopeInfo: scope, message: nil) 287 .task { await viewDidLoad2() } 288 } 289 } 290 } 291 // MARK: - 292 #if DEBUG 293 //struct ManualWithdraw_Previews: PreviewProvider { 294 // struct StateContainer : View { 295 // @State private var amountToPreview = Amount(currency: LONGCURRENCY, cent: 510) 296 // @State private var exchange: Exchange? = Exchange(exchangeBaseUrl: DEMOEXCHANGE, 297 // scopeInfo: ScopeInfo(type: .exchange, currency: LONGCURRENCY), 298 // paytoUris: [], 299 // tosStatus: .accepted, 300 // exchangeEntryStatus: .ephemeral, 301 // exchangeUpdateStatus: .ready, 302 // ageRestrictionOptions: []) 303 // 304 // var body: some View { 305 // ManualWithdraw(stack: CallStack("Preview"), isSheet: false, 306 // scopeInfo: <#ScopeInfo?#>, 307 // exchangeBaseUrl: DEMOEXCHANGE, 308 // exchange: $exchange, 309 // amountToTransfer: $amountToPreview) 310 // } 311 // } 312 // 313 // static var previews: some View { 314 // StateContainer() 315 // } 316 //} 317 #endif