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