taler-ios

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

RequestPayment.swift (15641B)


      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 when tapping [􁉅Request]
     13 struct RequestPayment: View {
     14     private let symLog = SymLogV(0)
     15     let stack: CallStack
     16     // when Action is tapped while in currency TransactionList…
     17     let selectedBalance: Balance?   // …then use THIS balance, otherwise show picker
     18     @Binding var amountLastUsed: Amount
     19     @Binding var summary: String
     20     @Binding var iconID: String?
     21 
     22     @EnvironmentObject private var controller: Controller
     23 
     24 //#if OIM
     25     @StateObject private var cash: OIMcash
     26 //#endif
     27     @State private var balanceIndex = 0
     28     @State private var balance: Balance? = nil      // nil only when balances == []
     29     @State private var currencyInfo: CurrencyInfo = CurrencyInfo.zero(UNKNOWN)
     30     @State private var amountToTransfer = Amount.zero(currency: EMPTYSTRING)    // Update currency when used
     31 
     32     init(stack: CallStack,
     33          selectedBalance: Balance?,
     34          amountLastUsed: Binding<Amount>,
     35          summary: Binding<String>,
     36          iconID: Binding<String?>,
     37          oimEuro: Bool
     38     ) {
     39         // SwiftUI ensures that the initialization uses the
     40         // closure only once during the lifetime of the view, so
     41         // later changes to the currency have no effect.
     42         self.stack = stack
     43         self.selectedBalance = selectedBalance
     44         self._amountLastUsed = amountLastUsed
     45         self._summary = summary
     46         self._iconID = iconID
     47         let oimCurrency = oimCurrency(selectedBalance?.scopeInfo, oimEuro: oimEuro)      // might be nil ==> OIMeuros
     48         let oimCash = OIMcash(oimCurrency)
     49         self._cash = StateObject(wrappedValue: { oimCash }())
     50     }
     51 
     52     private func firstWithP2P(_ balances : inout [Balance]) {
     53         let first = Balance.firstWithP2P(balances)
     54         symLog.log(first?.scopeInfo.currency)
     55         balance = first
     56     }
     57     @MainActor
     58     private func viewDidLoad() async {
     59         var balances = controller.balances
     60         if let selectedBalance {
     61             let disablePeerPayments = selectedBalance.disablePeerPayments ?? false
     62             if disablePeerPayments {
     63                 // find another balance
     64                 firstWithP2P(&balances)
     65             } else {
     66                 balance = selectedBalance
     67             }
     68         } else {
     69             firstWithP2P(&balances)
     70         }
     71         if let balance {
     72             balanceIndex = balances.firstIndex(of: balance) ?? 0
     73         } else {
     74             balanceIndex = 0
     75             balance = (balances.count > 0) ? balances[0] : nil
     76         }
     77     }
     78 
     79     private func navTitle(_ currency: String, _ condition: Bool = false) -> String {
     80         condition ? String(localized: "NavTitle_Request_Currency",
     81                         defaultValue: "Request \(currency)",
     82                              comment: "NavTitle: Request 'currency'")
     83                   : String(localized: "NavTitle_Request",
     84                         defaultValue: "Request",
     85                              comment: "NavTitle: Request")
     86     }
     87 
     88     @MainActor
     89     private func newBalance() async {
     90         // runs whenever the user changes the exchange via ScopePicker, or on new currencyInfo
     91         if let balance {
     92             // needed to update navTitle
     93             currencyInfo = controller.info(for: balance.scopeInfo, controller.currencyTicker)
     94         }
     95     }
     96 
     97     var body: some View {
     98 #if PRINT_CHANGES
     99         let _ = Self._printChanges()
    100 #endif
    101         let currencySymbol = currencyInfo.symbol
    102         let navA11y = navTitle(currencyInfo.name)                               // always include currency for a11y
    103         let navTitle = navTitle(currencySymbol, currencyInfo.hasSymbol)
    104         let count = controller.balances.count
    105         let _ = symLog.log("count = \(count)")
    106         let scrollView = ScrollView {
    107             if count > 0 {
    108                 ScopePicker(stack: stack.push(),
    109                             value: $balanceIndex,
    110                       onlyNonZero: false)
    111                 { index in
    112                         balanceIndex = index
    113                         balance = controller.balances[index]
    114                 }
    115                 .padding(.horizontal)
    116                 .padding(.bottom, 4)
    117             }
    118             if let balance {
    119                 RequestPaymentContent(stack: stack.push(),
    120                                        cash: cash,
    121                                     balance: balance,
    122                              amountLastUsed: $amountLastUsed,
    123                            amountToTransfer: $amountToTransfer,
    124                                     summary: $summary,
    125                                      iconID: $iconID)
    126             } else {    // TODO: Error no balance - Yikes
    127                 Text("No balance. There seems to be a problem with the database...")
    128             }
    129         } // ScrollView
    130             .navigationTitle(navTitle)
    131             .ignoresSafeArea(.keyboard, edges: .bottom)
    132             .frame(maxWidth: .infinity, alignment: .leading)
    133             .background(WalletColors().backgroundColor.edgesIgnoringSafeArea(.all))
    134             .task { await viewDidLoad() }
    135             .task(id: balanceIndex + (1000 * controller.currencyTicker)) { await newBalance() }
    136 
    137         if #available(iOS 16.4, *) {
    138             scrollView.toolbar(.hidden, for: .tabBar)
    139                 .scrollBounceBehavior(.basedOnSize)
    140         } else {
    141             scrollView
    142         }
    143     }
    144 }
    145 // MARK: -
    146 struct RequestPaymentContent: View {
    147     private let symLog = SymLogV(0)
    148     let stack: CallStack
    149     let cash: OIMcash
    150     let balance: Balance
    151     @Binding var amountLastUsed: Amount
    152     @Binding var amountToTransfer: Amount
    153     @Binding var summary: String
    154     @Binding var iconID: String?
    155 
    156     @EnvironmentObject private var controller: Controller
    157     @EnvironmentObject private var model: WalletModel
    158     @AppStorage("minimalistic") var minimalistic: Bool = false
    159 
    160     @State private var peerPullCheck: CheckPeerPullCreditResponse? = nil
    161     @State private var expireDays: UInt = 0
    162 //    @State private var feeAmount: Amount? = nil
    163     @State private var feeString = (EMPTYSTRING, EMPTYSTRING)
    164     @State private var buttonSelected = false
    165     @State private var shortcutSelected = false
    166     @State private var amountShortcut = Amount.zero(currency: EMPTYSTRING)      // Update currency when used
    167     @State private var amountZero = Amount.zero(currency: EMPTYSTRING)          // needed for isZero
    168     @State private var exchange: Exchange? = nil                                // wg. noFees and tosAccepted
    169 
    170     private func shortcutAction(_ shortcut: Amount) {
    171         amountShortcut = shortcut
    172         shortcutSelected = true
    173     }
    174     private func buttonAction() { buttonSelected = true }
    175 
    176     private func feeLabel(_ feeStr: String) -> String {
    177         feeStr.count > 0 ? String(localized: "- \(feeStr) fee")
    178                          : EMPTYSTRING
    179     }
    180 
    181     private func fee(raw: Amount, effective: Amount) -> Amount? {
    182         do {     // Incoming: fee = raw - effective
    183             let fee = try raw - effective
    184             return fee
    185         } catch {}
    186         return nil
    187     }
    188 
    189     private func feeIsNotZero() -> Bool? {
    190         if let hasNoFees = exchange?.noFees {
    191             if hasNoFees {
    192                 return nil      // this exchange never has fees
    193             }
    194         }
    195         return peerPullCheck != nil ? true : false
    196     }
    197 
    198     @MainActor
    199     private func computeFee(_ amount: Amount) async -> ComputeFeeResult? {
    200         if amount.isZero {
    201             return ComputeFeeResult.zero()
    202         }
    203             if exchange == nil {
    204                 if let url = balance.scopeInfo.url {
    205                     exchange = try? await model.getExchangeByUrl(url: url)
    206                 }
    207             }
    208             do {
    209                 let baseURL = exchange?.exchangeBaseUrl
    210                 let ppCheck = try await model.checkPeerPullCredit(amount, scope: balance.scopeInfo, viewHandles: true)
    211                 let raw = ppCheck.amountRaw
    212                 let effective = ppCheck.amountEffective
    213                 if let fee = fee(raw: raw, effective: effective) {
    214                     feeString = fee.formatted(balance.scopeInfo, isNegative: false)
    215                     symLog.log("Fee = \(feeString.0)")
    216 
    217                     peerPullCheck = ppCheck
    218                     let feeLabel = (feeLabel(feeString.0), feeLabel(feeString.1))
    219 //                    announce("\(amountVoiceOver), \(feeLabel)")
    220                     return ComputeFeeResult(insufficient: false,
    221                                                feeAmount: fee,
    222                                                   feeStr: feeLabel,             // TODO: feeLabelA11y
    223                                                 numCoins: ppCheck.numCoins)
    224                 } else {
    225                     peerPullCheck = nil
    226                 }
    227             } catch {
    228                 // handle cancel, errors
    229                 symLog.log("❗️ \(error), \(error.localizedDescription)")
    230                 switch error {
    231                     case let walletError as WalletBackendError:
    232                         switch walletError {
    233                             case .walletCoreError(let wError):
    234                                 if wError?.code == 7027 {
    235                                     return ComputeFeeResult.insufficient()
    236                                 }
    237                             default: break
    238                         }
    239                     default: break
    240                 }
    241             }
    242         return nil
    243     } // computeFee
    244 
    245     func updateExchange(_ baseURL: String) async {
    246         if exchange == nil ||
    247             exchange?.exchangeBaseUrl != baseURL ||
    248             exchange?.tosStatus != .accepted
    249         {
    250             symLog.log("getExchangeByUrl(\(baseURL))")
    251             exchange = try? await model.getExchangeByUrl(url: baseURL)
    252         }
    253     }
    254 
    255     @MainActor
    256     private func newBalance() async {
    257         let scope = balance.scopeInfo
    258         symLog.log("❗️ newBalance( \(scope.currency) )")
    259         amountToTransfer.setCurrency(scope.currency)
    260         if amountToTransfer.isZero {
    261             if let baseURL = scope.url {
    262                 await self.updateExchange(baseURL)
    263             } else {
    264                 // TODO: get tosStatus for global currency
    265             }
    266         } else {
    267             let ppCheck = try? await model.checkPeerPullCredit(amountToTransfer, scope: scope, viewHandles: false)
    268             if let ppCheck {
    269                 if let baseURL = ppCheck.scopeInfo?.url ?? ppCheck.exchangeBaseUrl {
    270                     await self.updateExchange(baseURL)
    271                 }
    272                 peerPullCheck = ppCheck
    273             }
    274         }
    275     }
    276 
    277     var body: some View {
    278 #if PRINT_CHANGES
    279         let _ = Self._printChanges()
    280         let _ = symLog.vlog()       // just to get the # to compare it with .onAppear & onDisappear
    281 #endif
    282         Group {
    283             let coinData = CoinData(details: peerPullCheck)
    284 //                let availableStr = amountAvailable.formatted(currencyInfo, isNegative: false)
    285 //                let amountVoiceOver = amountToTransfer.formatted(currencyInfo, isNegative: false)
    286                 let feeLabel = coinData.feeLabel(balance.scopeInfo,
    287                                         feeZero: String(localized: "No fee"),
    288                                      isNegative: false)
    289                 let inputDestination = P2PSubjectV(stack: stack.push(),
    290                                                     cash: cash,
    291                                                    scope: balance.scopeInfo,
    292                                                available: balance.available,
    293                                                 feeLabel: feeLabel,
    294                                             feeIsNotZero: feeIsNotZero(),
    295                                                 outgoing: false,
    296                                         amountToTransfer: $amountToTransfer,
    297                                                  summary: $summary,
    298                                                   iconID: $iconID,
    299                                               expireDays: $expireDays)
    300                 let shortcutDestination = P2PSubjectV(stack: stack.push(),
    301                                                        cash: cash,
    302                                                       scope: balance.scopeInfo,
    303                                                   available: balance.available,
    304                                                    feeLabel: nil,
    305                                                feeIsNotZero: feeIsNotZero(),
    306                                                    outgoing: false,
    307                                            amountToTransfer: $amountShortcut,
    308                                                     summary: $summary,
    309                                                      iconID: $iconID,
    310                                                  expireDays: $expireDays)
    311                 let actions = Group {
    312                     NavLink($buttonSelected) { inputDestination }
    313                     NavLink($shortcutSelected) { shortcutDestination }
    314                 }
    315                 let tosAccepted = (exchange?.tosStatus == .accepted) ?? false
    316                 if tosAccepted {
    317                     let a11yLabel = String(localized: "Amount to request:", comment: "a11y, no abbreviations")
    318                     let amountLabel = minimalistic ? String(localized: "Amount:")
    319                                                    : a11yLabel
    320                     AmountInputV(stack: stack.push(),
    321                                  scope: balance.scopeInfo,
    322                        amountAvailable: $amountZero,        // incoming needs no available
    323                            amountLabel: amountLabel,
    324                              a11yLabel: a11yLabel,
    325                       amountToTransfer: $amountToTransfer,
    326                         amountLastUsed: amountLastUsed,
    327                                wireFee: nil,
    328                                summary: $summary,
    329                         shortcutAction: shortcutAction,
    330                           buttonAction: buttonAction,
    331                             isIncoming: true,
    332                             computeFee: computeFee)
    333                     .background(actions)
    334                 } else {
    335                     let baseURL = peerPullCheck?.scopeInfo?.url ??
    336                                   peerPullCheck?.exchangeBaseUrl ??
    337                                   balance.scopeInfo.url
    338                     ToSButtonView(stack: stack.push(),
    339                         exchangeBaseUrl: baseURL,
    340                                  viewID: VIEW_P2P_TOS,   // 31 WithdrawTOSView   TODO: YIKES might be withdraw-exchange
    341                                     p2p: false,
    342                            acceptAction: nil)
    343                         .padding(.top)
    344                 }
    345         }
    346         .task(id: balance) { await newBalance() }
    347         .onAppear {
    348             DebugViewC.shared.setViewID(VIEW_P2P_REQUEST, stack: stack.push())
    349             symLog.log("❗️ onAppear")
    350         }
    351         .onDisappear {
    352             symLog.log("❗️ onDisappear")
    353         }
    354     } // body
    355 }
    356 // MARK: -
    357 #if DEBUG
    358 //struct ReceiveAmount_Previews: PreviewProvider {
    359 //    static var scopeInfo = ScopeInfo(type: ScopeInfo.ScopeInfoType.exchange, exchangeBaseUrl: DEMOEXCHANGE, currency: LONGCURRENCY)
    360 //    static var previews: some View {
    361 //        let amount = Amount(currency: LONGCURRENCY, integer: 10, fraction: 0)
    362 //        RequestPayment(exchangeBaseUrl: DEMOEXCHANGE, currency: LONGCURRENCY)
    363 //    }
    364 //}
    365 #endif