taler-ios

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

WithdrawUriScan.swift (11925B)


      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 either when scanning a QR code or tapping the provided link, both from the bank's website.
     13 // We show the user the bank-integrated withdrawal details in a sheet - but first the ToS must be accepted.
     14 // After the user confirmed the withdrawal, we show a button to return to the bank website to authorize (2FA)
     15 struct WithdrawUriScan: View {
     16     private let symLog = SymLogV(0)
     17     let stack: CallStack
     18 
     19     // the URL from the bank website
     20     let url: URL
     21 
     22     @EnvironmentObject private var controller: Controller
     23     @EnvironmentObject private var model: WalletModel
     24     @AppStorage("minimalistic") var minimalistic: Bool = false
     25     @AppStorage("myListStyle") var myListStyle: MyListStyle = .automatic
     26 
     27     @State private var withdrawUriInfo: WithdrawUriInfoResponse? = nil
     28     @State private var amountIsEditable = false
     29     @State private var amountToTransfer = Amount.zero(currency: EMPTYSTRING)    // Update currency when used
     30     @State private var amountShortcut = Amount.zero(currency: EMPTYSTRING)      // Update currency when used
     31     @State private var amountLastUsed = Amount.zero(currency: EMPTYSTRING)      // Update currency when used
     32     @State private var amountZero = Amount.zero(currency: EMPTYSTRING)          // needed for isZero
     33     @State private var amountLastChecked = Amount.zero(currency: EMPTYSTRING)   //
     34     @State private var buttonSelected = false
     35     @State private var shortcutSelected = false
     36     @State private var amountAvailable: Amount? = nil
     37     @State private var wireFee: Amount? = nil
     38     @State private var summary = EMPTYSTRING
     39 
     40     @State private var selectedExchange = EMPTYSTRING
     41     @State private var exchange: Exchange? = nil
     42     @State private var exchangeError: Bool = false
     43     @State private var possibleExchanges: [Exchange] = []
     44     @State private var defaultExchangeBaseUrl: String?     // if nil then use possibleExchanges
     45     @State private var currencyInfo: CurrencyInfo = CurrencyInfo.zero(UNKNOWN)
     46 
     47 //    @State private var feeAmount: Amount? = nil
     48     @State private var withdrawalDetails: WithdrawalDetailsForAmount? = nil
     49 
     50     let navTitle = String(localized: "Withdrawal")
     51 
     52     @MainActor
     53     func loadExchange(_ baseUrl: String) async {        // TODO: throws?
     54         if let someExchange = try? await model.getExchangeByUrl(url: baseUrl) {
     55             symLog.log("Loaded \(baseUrl.trimURL)")
     56             exchange = someExchange
     57         }
     58     }
     59 
     60     private func shortcutAction(_ shortcut: Amount) {
     61         amountShortcut = shortcut
     62         shortcutSelected = true
     63     }
     64     private func buttonAction() { buttonSelected = true }
     65 
     66     private func feeLabel(_ feeString: String) -> String {
     67         feeString.isEmpty ? EMPTYSTRING : String(localized: "\(feeString) fee")
     68     }
     69 
     70     @MainActor
     71     private func computeFeeWithdraw(_ amount: Amount) async -> ComputeFeeResult? {
     72         if let exchange {
     73           if amountLastChecked != amount {
     74             amountLastChecked = amount
     75 
     76             // viewHandles: the next digit's call has the same progressToken (baseUrl), so wallet-core
     77             // cancels this one with WALLET_CORE_REQUEST_CANCELLED - not an error to show the user
     78             if let details = try? await model.getWithdrawalDetailsForAmount(amount,
     79                                                                    baseUrl: exchange.exchangeBaseUrl,
     80                                                                      scope: nil,       // TODO: scope
     81                                                                viewHandles: true) {
     82                 let fee = try? details.amountRaw - details.amountEffective
     83                 let feeStr = fee?.formatted(currencyInfo, isNegative: true) ?? ("0", "0")
     84                 symLog.log("Fee = \(feeStr.0)")
     85                 // amountAvailable is the bank's maxAmount: only more than that is too much
     86                 let insufficient = if let amountAvailable {
     87                     (try? details.amountRaw > amountAvailable) ?? true
     88                 } else {
     89                     false
     90                 }
     91                 withdrawalDetails = details
     92                 return ComputeFeeResult(insufficient: insufficient, feeAmount: fee,
     93                                               feeStr: (feeLabel(feeStr.0), feeLabel(feeStr.1)),
     94                                             numCoins: details.numCoins)
     95             } else {
     96                 // TODO: don't call this again
     97             }
     98           }
     99         } else {
    100             symLog.log("No exchange!")
    101         }
    102         return nil
    103     }
    104 
    105     @MainActor
    106     private func viewDidLoad() async {
    107         symLog.log(".task")
    108         do {
    109             let response = try await model.prepareBankIntegratedWithdrawal(url.trimmedString)
    110             let txId = response.transactionId
    111             let uriInfoResponse = response.info
    112             let amount = uriInfoResponse.amount
    113             let currency = amount?.currencyStr ?? uriInfoResponse.currency
    114             amountToTransfer = amount ?? Amount.zero(currency: currency)
    115             amountIsEditable = uriInfoResponse.editableAmount
    116             amountAvailable = uriInfoResponse.maxAmount     // may be nil
    117             if let available = amountAvailable {
    118                 amountZero = available
    119             }
    120             wireFee = uriInfoResponse.wireFee               // may be nil
    121             defaultExchangeBaseUrl = uriInfoResponse.defaultExchangeBaseUrl
    122             possibleExchanges = uriInfoResponse.possibleExchanges
    123             let baseUrl = defaultExchangeBaseUrl
    124                        ?? possibleExchanges.first?.exchangeBaseUrl
    125                        ?? nil
    126 
    127 //            await loadExchange(baseUrl)       <- checkCurrencyInfo now returns the exchange
    128 //            symLog.log("\(baseUrl.trimURL) loaded")
    129 
    130             if let baseUrl, let someExchange = try? await controller.checkCurrencyInfo(for: baseUrl) {
    131                 symLog.log("Info(for: \(baseUrl.trimURL) loaded")
    132                 exchange = someExchange
    133             } else {
    134                 exchangeError = true
    135             }
    136         } catch {
    137             // TODO: error, dismiss
    138         }
    139     }
    140 
    141     var body: some View {
    142 #if PRINT_CHANGES
    143         let _ = Self._printChanges()
    144         let _ = symLog.vlog(url.absoluteString)       // just to get the # to compare it with .onAppear & onDisappear
    145 #endif
    146         if let exchange2 = exchange {
    147             if let defaultBaseUrl = defaultExchangeBaseUrl ?? possibleExchanges.first?.exchangeBaseUrl {
    148                 VStack {
    149                     let title = String(localized: "using:", comment: "using: exchange.taler.net")
    150                     if possibleExchanges.count > 1 {
    151                         Picker(title, selection: $selectedExchange) {
    152                             ForEach(possibleExchanges, id: \.self) { exchange in
    153                                 let baseUrl = exchange.exchangeBaseUrl
    154                                 Text(baseUrl)
    155                             }
    156                         }
    157                         .talerFont(.title3)
    158                         .pickerStyle(.menu)
    159                         .onAppear() {  selectedExchange = defaultBaseUrl }
    160                         .onChange(of: selectedExchange) { selected in
    161                             Task { await loadExchange(selected) }
    162                         }
    163                     } // load defaultBaseUrl
    164                     let acceptDestination = WithdrawAcceptView(stack: stack.push(),
    165                                                                  url: url,
    166                                                                scope: exchange2.scopeInfo,
    167                                                     amountToTransfer: $amountToTransfer,
    168                                                              wireFee: $wireFee,
    169                                                             exchange: $exchange)        // from possibleExchanges
    170                     if amountIsEditable {
    171                         ScrollView {
    172                             let shortcutDest = WithdrawAcceptView(stack: stack.push(),
    173                                                                     url: url,
    174                                                                   scope: exchange2.scopeInfo,
    175                                                        amountToTransfer: $amountShortcut,
    176                                                                 wireFee: $wireFee,
    177                                                                exchange: $exchange)    // from possibleExchanges
    178                             let actions = Group {
    179                                 NavLink($shortcutSelected) { shortcutDest }
    180                                 NavLink($buttonSelected) { acceptDestination }
    181                             }
    182                             let a11yLabel = String(localized: "Amount to withdraw:", comment: "a11y, no abbreviations")
    183                             let amountLabel = minimalistic ? String(localized: "Amount:")        // maxAmount
    184                                                            : a11yLabel
    185                             // TODO: input amount, then
    186                             AmountInputV(stack: stack.push(),
    187                                          scope: exchange2.scopeInfo,
    188                                amountAvailable: $amountZero,
    189                                    amountLabel: amountZero.isZero ? amountLabel : nil,
    190                                      a11yLabel: a11yLabel,
    191                               amountToTransfer: $amountToTransfer,
    192                                 amountLastUsed: amountLastUsed,
    193                                        wireFee: wireFee,
    194                                        summary: $summary,
    195                                 shortcutAction: shortcutAction,
    196                                   buttonAction: buttonAction,
    197                                     isIncoming: true,
    198                                     computeFee: computeFeeWithdraw)
    199                             .background(actions)
    200                         } // ScrollView
    201 //                        .ignoresSafeArea(.keyboard, edges: .bottom)
    202                     } else {
    203                         acceptDestination
    204                     } // directly show the accept view
    205                 }
    206                 .navigationTitle(navTitle)
    207                 .task(id: controller.currencyTicker) {
    208                     currencyInfo = controller.info(for: exchange2.scopeInfo, controller.currencyTicker)
    209                 }
    210                 .onAppear() {
    211                     symLog.log("onAppear")
    212                     DebugViewC.shared.setSheetID(SHEET_WITHDRAWAL, stack: stack.push())
    213                 }
    214 //              agePicker.setAges(ages: details?.ageRestrictionOptions)
    215             }
    216         } else if exchangeError {
    217             let possibleExchangeBaseUrl: String? = possibleExchanges.first?.exchangeBaseUrl
    218             let message = defaultExchangeBaseUrl != nil ? String(localized: "Payment service \(defaultExchangeBaseUrl ?? EMPTYSTRING) could not be loaded.")
    219                        : possibleExchanges.first != nil ? String(localized: "No default - first possible payment service: \(possibleExchangeBaseUrl ?? EMPTYSTRING)")
    220                                                         : String(localized: "No default, no possible payment services")
    221             ErrorView(stack.push(),
    222                       title: String(localized: "Error loading payment service details:"),
    223                     message: message,
    224                    copyable: true)
    225         } else {        // no details or no exchange
    226 #if DEBUG
    227             let message = url.host
    228 #else
    229             let message: String? = nil
    230 #endif
    231             LoadingView(stack: stack.push(), scopeInfo: nil, message: message)
    232                 .task { await viewDidLoad() }
    233         }
    234     }
    235 }