taler-ios

iOS apps for GNU Taler (wallet)
Log | Files | Refs | README | LICENSE

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 }