taler-ios

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

WithdrawURIView.swift (11413B)


      1 /*
      2  * This file is part of GNU Taler, ©2022-25 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 WithdrawURIView: 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.count > 0 ? String(localized: "\(feeString) fee")
     68         : EMPTYSTRING
     69     }
     70 
     71     @MainActor
     72     private func computeFeeWithdraw(_ amount: Amount) async -> ComputeFeeResult? {
     73         if let exchange {
     74           if amountLastChecked != amount {
     75             amountLastChecked = amount
     76 
     77             if let details = try? await model.getWithdrawalDetailsForAmount(amount,
     78                                                                    baseUrl: exchange.exchangeBaseUrl,
     79                                                                      scope: nil) {     // TODO: scope
     80                 let fee = try? details.amountRaw - details.amountEffective
     81                 let feeStr = fee?.formatted(currencyInfo, isNegative: true) ?? ("0", "0")
     82                 symLog.log("Fee = \(feeStr.0)")
     83                 let insufficient = if let amountAvailable {
     84                     (try? details.amountRaw < amountAvailable) ?? true
     85                 } else {
     86                     false
     87                 }
     88                 withdrawalDetails = details
     89                 return ComputeFeeResult(insufficient: insufficient, feeAmount: fee,
     90                                               feeStr: (feeLabel(feeStr.0), feeLabel(feeStr.1)),
     91                                             numCoins: details.numCoins)
     92             } else {
     93                 // TODO: don't call this again
     94             }
     95           }
     96         } else {
     97             symLog.log("No exchange!")
     98         }
     99         return nil
    100     }
    101 
    102     @MainActor
    103     private func viewDidLoad() async {
    104         symLog.log(".task")
    105         do {
    106             let uriInfoResponse = try await model.getWithdrawalDetailsForUri(url.absoluteString)
    107             let amount = uriInfoResponse.amount
    108             let currency = amount?.currencyStr ?? uriInfoResponse.currency
    109             amountToTransfer = amount ?? Amount.zero(currency: currency)
    110             amountIsEditable = uriInfoResponse.editableAmount
    111             amountAvailable = uriInfoResponse.maxAmount     // may be nil
    112             if let available = amountAvailable {
    113                 amountZero = available
    114             }
    115             wireFee = uriInfoResponse.wireFee               // may be nil
    116             defaultExchangeBaseUrl = uriInfoResponse.defaultExchangeBaseUrl
    117             possibleExchanges = uriInfoResponse.possibleExchanges
    118             let baseUrl = defaultExchangeBaseUrl
    119                        ?? possibleExchanges.first?.exchangeBaseUrl
    120                        ?? nil
    121 
    122 //            await loadExchange(baseUrl)       <- checkCurrencyInfo now returns the exchange
    123 //            symLog.log("\(baseUrl.trimURL) loaded")
    124 
    125             if let baseUrl, let someExchange = try? await controller.checkCurrencyInfo(for: baseUrl, model: model) {
    126                 symLog.log("Info(for: \(baseUrl.trimURL) loaded")
    127                 exchange = someExchange
    128             } else {
    129                 exchangeError = true
    130             }
    131         } catch {
    132             // TODO: error, dismiss
    133         }
    134     }
    135 
    136     var body: some View {
    137 #if PRINT_CHANGES
    138         let _ = Self._printChanges()
    139         let _ = symLog.vlog(url.absoluteString)       // just to get the # to compare it with .onAppear & onDisappear
    140 #endif
    141         if let exchange2 = exchange {
    142             if let defaultBaseUrl = defaultExchangeBaseUrl ?? possibleExchanges.first?.exchangeBaseUrl {
    143                 VStack {
    144                     let title = String(localized: "using:", comment: "using: exchange.taler.net")
    145                     if possibleExchanges.count > 1 {
    146                         Picker(title, selection: $selectedExchange) {
    147                             ForEach(possibleExchanges, id: \.self) { exchange in
    148                                 let baseUrl = exchange.exchangeBaseUrl
    149                                 Text(baseUrl)
    150                             }
    151                         }
    152                         .talerFont(.title3)
    153                         .pickerStyle(.menu)
    154                         .onAppear() {  selectedExchange = defaultBaseUrl }
    155                         .onChange(of: selectedExchange) { selected in
    156                             Task { await loadExchange(selected) }
    157                         }
    158                     } // load defaultBaseUrl
    159                     let acceptDestination = WithdrawAcceptView(stack: stack.push(),
    160                                                                  url: url,
    161                                                                scope: exchange2.scopeInfo,
    162                                                     amountToTransfer: $amountToTransfer,
    163                                                              wireFee: $wireFee,
    164                                                             exchange: $exchange)        // from possibleExchanges
    165                     if amountIsEditable {
    166                         ScrollView {
    167                             let shortcutDest = WithdrawAcceptView(stack: stack.push(),
    168                                                                     url: url,
    169                                                                   scope: exchange2.scopeInfo,
    170                                                        amountToTransfer: $amountShortcut,
    171                                                                 wireFee: $wireFee,
    172                                                                exchange: $exchange)    // from possibleExchanges
    173                             let actions = Group {
    174                                 NavLink($shortcutSelected) { shortcutDest }
    175                                 NavLink($buttonSelected) { acceptDestination }
    176                             }
    177                             let a11yLabel = String(localized: "Amount to withdraw:", comment: "a11y, no abbreviations")
    178                             let amountLabel = minimalistic ? String(localized: "Amount:")        // maxAmount
    179                                                            : a11yLabel
    180                             // TODO: input amount, then
    181                             AmountInputV(stack: stack.push(),
    182                                          scope: exchange2.scopeInfo,
    183                                amountAvailable: $amountZero,
    184                                    amountLabel: amountZero.isZero ? amountLabel : nil,
    185                                      a11yLabel: a11yLabel,
    186                               amountToTransfer: $amountToTransfer,
    187                                 amountLastUsed: amountLastUsed,
    188                                        wireFee: wireFee,
    189                                        summary: $summary,
    190                                 shortcutAction: shortcutAction,
    191                                   buttonAction: buttonAction,
    192                                     isIncoming: true,
    193                                     computeFee: computeFeeWithdraw)
    194                             .background(actions)
    195                         } // ScrollView
    196                     } else {
    197                         acceptDestination
    198                     } // directly show the accept view
    199                 }
    200                 .navigationTitle(navTitle)
    201                 .task(id: controller.currencyTicker) {
    202                     currencyInfo = controller.info(for: exchange2.scopeInfo, controller.currencyTicker)
    203                 }
    204                 .onAppear() {
    205                     symLog.log("onAppear")
    206                     DebugViewC.shared.setSheetID(SHEET_WITHDRAWAL)
    207                 }
    208 //              agePicker.setAges(ages: details?.ageRestrictionOptions)
    209             }
    210         } else if exchangeError {
    211             let possibleExchangeBaseUrl: String? = possibleExchanges.first?.exchangeBaseUrl
    212             let message = defaultExchangeBaseUrl != nil ? "defaultExchangeBaseUrl: \(defaultExchangeBaseUrl)"
    213                        : possibleExchanges.first != nil ? String(localized: "No default - first possible payment service: \(possibleExchangeBaseUrl ?? EMPTYSTRING)")
    214                                                         : String(localized: "No default, no possible payment services")
    215             ErrorView(stack.push(),
    216                       title: String(localized: "Error loading payment service details:"),
    217                     message: message,
    218                     copyable: true
    219             ) {
    220                 dismissTop(stack.push())
    221             }
    222         } else {        // no details or no exchange
    223 #if DEBUG
    224             let message = url.host
    225 #else
    226             let message: String? = nil
    227 #endif
    228             LoadingView(stack: stack.push(), scopeInfo: nil, message: message)
    229                 .task { await viewDidLoad() }
    230         }
    231     }
    232 }