taler-ios

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

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