taler-ios

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

RequestPayment.swift (15646B)


      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 [􁉅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.isEmpty ? nil : balances[0]
     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 > 1 {
    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             }   // TODO: else show static text?
    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.isEmpty ? EMPTYSTRING : String(localized: "- \(feeStr) fee")
    178     }
    179 
    180     private func fee(raw: Amount, effective: Amount) -> Amount? {
    181         do {     // Incoming: fee = raw - effective
    182             let fee = try raw - effective
    183             return fee
    184         } catch {}
    185         return nil
    186     }
    187 
    188     private func feeIsNotZero() -> Bool? {
    189         if let hasNoFees = exchange?.noFees {
    190             if hasNoFees {
    191                 return nil      // this exchange never has fees
    192             }
    193         }
    194         return peerPullCheck != nil ? true : false
    195     }
    196 
    197     @MainActor
    198     private func computeFee(_ amount: Amount) async -> ComputeFeeResult? {
    199         if amount.isZero {
    200             return ComputeFeeResult.zero()
    201         }
    202             if exchange == nil {
    203                 if let url = balance.scopeInfo.url {
    204                     exchange = try? await model.getExchangeByUrl(url: url)
    205                 }
    206             }
    207             do {
    208                 let baseURL = exchange?.exchangeBaseUrl
    209                 let ppCheck = try await model.checkPeerPullCredit(amount, scope: balance.scopeInfo, viewHandles: true)
    210                 let raw = ppCheck.amountRaw
    211                 let effective = ppCheck.amountEffective
    212                 if let fee = fee(raw: raw, effective: effective) {
    213                     feeString = fee.formatted(balance.scopeInfo, isNegative: false)
    214                     symLog.log("Fee = \(feeString.0)")
    215 
    216                     peerPullCheck = ppCheck
    217                     let feeLabel = (feeLabel(feeString.0), feeLabel(feeString.1))
    218 //                    announce("\(amountVoiceOver), \(feeLabel)")
    219                     return ComputeFeeResult(insufficient: false,
    220                                                feeAmount: fee,
    221                                                   feeStr: feeLabel,             // TODO: feeLabelA11y
    222                                                 numCoins: ppCheck.numCoins)
    223                 } else {
    224                     peerPullCheck = nil
    225                 }
    226             } catch {
    227                 // handle cancel, errors
    228                 symLog.log("❗️ \(error), \(error.localizedDescription)")
    229                 switch error {
    230                     case let walletError as WalletBackendError:
    231                         switch walletError {
    232                             case .walletCoreError(let wError):
    233                                 if wError?.code == 7027 {
    234                                     return ComputeFeeResult.insufficient()
    235                                 }
    236                             default: break
    237                         }
    238                     default: break
    239                 }
    240             }
    241         return nil
    242     } // computeFee
    243 
    244     func updateExchange(_ baseURL: String) async {
    245         if exchange == nil ||
    246             exchange?.exchangeBaseUrl != baseURL ||
    247             exchange?.tosStatus != .accepted
    248         {
    249             symLog.log("getExchangeByUrl(\(baseURL))")
    250             exchange = try? await model.getExchangeByUrl(url: baseURL)
    251         }
    252     }
    253 
    254     @MainActor
    255     private func newBalance() async {
    256         let scope = balance.scopeInfo
    257         symLog.log("❗️ newBalance( \(scope.currency) )")
    258         amountToTransfer.setCurrency(scope.currency)
    259         if amountToTransfer.isZero {
    260             if let baseURL = scope.url {
    261                 await self.updateExchange(baseURL)
    262             } else {
    263                 // TODO: get tosStatus for global currency
    264             }
    265         } else {
    266             let ppCheck = try? await model.checkPeerPullCredit(amountToTransfer, scope: scope, viewHandles: false)
    267             if let ppCheck {
    268                 if let baseURL = ppCheck.scopeInfo?.url ?? ppCheck.exchangeBaseUrl {
    269                     await self.updateExchange(baseURL)
    270                 }
    271                 peerPullCheck = ppCheck
    272             }
    273         }
    274     }
    275 
    276     var body: some View {
    277 #if PRINT_CHANGES
    278         let _ = Self._printChanges()
    279         let _ = symLog.vlog()       // just to get the # to compare it with .onAppear & onDisappear
    280 #endif
    281         Group {
    282             let coinData = CoinData(details: peerPullCheck)
    283 //                let availableStr = amountAvailable.formatted(currencyInfo, isNegative: false)
    284 //                let amountVoiceOver = amountToTransfer.formatted(currencyInfo, isNegative: false)
    285                 let feeLabel = coinData.feeLabel(balance.scopeInfo,
    286                                         feeZero: String(localized: "No fee"),
    287                                      isNegative: false)
    288                 let inputDestination = P2PSubjectV(stack: stack.push(),
    289                                                     cash: cash,
    290                                                    scope: balance.scopeInfo,
    291                                                available: balance.available,
    292                                                 feeLabel: feeLabel,
    293                                             feeIsNotZero: feeIsNotZero(),
    294                                                 outgoing: false,
    295                                         amountToTransfer: $amountToTransfer,
    296                                                  summary: $summary,
    297                                                   iconID: $iconID,
    298                                               expireDays: $expireDays)
    299                 let shortcutDestination = P2PSubjectV(stack: stack.push(),
    300                                                        cash: cash,
    301                                                       scope: balance.scopeInfo,
    302                                                   available: balance.available,
    303                                                    feeLabel: nil,
    304                                                feeIsNotZero: feeIsNotZero(),
    305                                                    outgoing: false,
    306                                            amountToTransfer: $amountShortcut,
    307                                                     summary: $summary,
    308                                                      iconID: $iconID,
    309                                                  expireDays: $expireDays)
    310                 let actions = Group {
    311                     NavLink($buttonSelected) { inputDestination }
    312                     NavLink($shortcutSelected) { shortcutDestination }
    313                 }
    314                 let tosAccepted = (exchange?.tosStatus == .accepted) ?? false
    315                 if tosAccepted {
    316                     let a11yLabel = String(localized: "Amount to request:", comment: "a11y, no abbreviations")
    317                     let amountLabel = minimalistic ? String(localized: "Amount:")
    318                                                    : a11yLabel
    319                     AmountInputV(stack: stack.push(),
    320                                  scope: balance.scopeInfo,
    321                        amountAvailable: $amountZero,        // incoming needs no available
    322                            amountLabel: amountLabel,
    323                              a11yLabel: a11yLabel,
    324                       amountToTransfer: $amountToTransfer,
    325                         amountLastUsed: amountLastUsed,
    326                                wireFee: nil,
    327                                summary: $summary,
    328                         shortcutAction: shortcutAction,
    329                           buttonAction: buttonAction,
    330                             isIncoming: true,
    331                             computeFee: computeFee)
    332                     .background(actions)
    333                 } else {
    334                     let baseURL = peerPullCheck?.scopeInfo?.url ??
    335                                   peerPullCheck?.exchangeBaseUrl ??
    336                                   balance.scopeInfo.url
    337                     ToSButtonView(stack: stack.push(),
    338                         exchangeBaseUrl: baseURL,
    339                                  viewID: VIEW_P2P_TOS,   // 31 WithdrawTOSView   TODO: YIKES might be withdraw-exchange
    340                                     p2p: false,
    341                            acceptAction: nil)
    342                         .padding(.top)
    343                 }
    344         }
    345         .task(id: balance) { await newBalance() }
    346         .onAppear {
    347             DebugViewC.shared.setViewID(VIEW_P2P_REQUEST, stack: stack.push())
    348             symLog.log("❗️ onAppear")
    349         }
    350         .onDisappear {
    351             symLog.log("❗️ onDisappear")
    352         }
    353     } // body
    354 }
    355 // MARK: -
    356 #if DEBUG
    357 //struct ReceiveAmount_Previews: PreviewProvider {
    358 //    static var scopeInfo = ScopeInfo(type: ScopeInfo.ScopeInfoType.exchange, exchangeBaseUrl: DEMOEXCHANGE, currency: LONGCURRENCY)
    359 //    static var previews: some View {
    360 //        let amount = Amount(currency: LONGCURRENCY, integer: 10, fraction: 0)
    361 //        RequestPayment(exchangeBaseUrl: DEMOEXCHANGE, currency: LONGCURRENCY)
    362 //    }
    363 //}
    364 #endif