taler-ios

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

RequestPayment.swift (15369B)


      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         symLog.log("❗️ task \(balanceIndex)")
     92         if let balance {
     93             let scopeInfo = balance.scopeInfo
     94             amountToTransfer.setCurrency(scopeInfo.currency)
     95             currencyInfo = controller.info(for: scopeInfo, controller.currencyTicker)
     96         }
     97     }
     98 
     99     var body: some View {
    100 #if PRINT_CHANGES
    101         let _ = Self._printChanges()
    102 #endif
    103         let currencySymbol = currencyInfo.symbol
    104         let navA11y = navTitle(currencyInfo.name)                               // always include currency for a11y
    105         let navTitle = navTitle(currencySymbol, currencyInfo.hasSymbol)
    106         let count = controller.balances.count
    107         let _ = symLog.log("count = \(count)")
    108         let scrollView = ScrollView {
    109             if count > 0 {
    110                 ScopePicker(stack: stack.push(),
    111                             value: $balanceIndex,
    112                       onlyNonZero: false)
    113                 { index in
    114                         balanceIndex = index
    115                         balance = controller.balances[index]
    116                 }
    117                 .padding(.horizontal)
    118                 .padding(.bottom, 4)
    119             }
    120             if let balance {
    121                 RequestPaymentContent(stack: stack.push(),
    122                                        cash: cash,
    123                                     balance: balance,
    124                              amountLastUsed: $amountLastUsed,
    125                            amountToTransfer: $amountToTransfer,
    126                                     summary: $summary,
    127                                      iconID: $iconID)
    128             } else {    // TODO: Error no balance - Yikes
    129                 Text("No balance. There seems to be a problem with the database...")
    130             }
    131         } // ScrollView
    132             .navigationTitle(navTitle)
    133             .frame(maxWidth: .infinity, alignment: .leading)
    134             .background(WalletColors().backgroundColor.edgesIgnoringSafeArea(.all))
    135             .task { await viewDidLoad() }
    136             .task(id: balanceIndex + (1000 * controller.currencyTicker)) { await newBalance() }
    137 
    138         if #available(iOS 16.4, *) {
    139             scrollView.toolbar(.hidden, for: .tabBar)
    140                 .scrollBounceBehavior(.basedOnSize)
    141         } else {
    142             scrollView
    143         }
    144     }
    145 }
    146 // MARK: -
    147 struct RequestPaymentContent: View {
    148     private let symLog = SymLogV(0)
    149     let stack: CallStack
    150     let cash: OIMcash
    151     let balance: Balance
    152     @Binding var amountLastUsed: Amount
    153     @Binding var amountToTransfer: Amount
    154     @Binding var summary: String
    155     @Binding var iconID: String?
    156 
    157     @EnvironmentObject private var controller: Controller
    158     @EnvironmentObject private var model: WalletModel
    159     @AppStorage("minimalistic") var minimalistic: Bool = false
    160 
    161     @State private var peerPullCheck: CheckPeerPullCreditResponse? = nil
    162     @State private var expireDays: UInt = 0
    163 //    @State private var feeAmount: Amount? = nil
    164     @State private var feeString = (EMPTYSTRING, EMPTYSTRING)
    165     @State private var buttonSelected = false
    166     @State private var shortcutSelected = false
    167     @State private var amountShortcut = Amount.zero(currency: EMPTYSTRING)      // Update currency when used
    168     @State private var amountZero = Amount.zero(currency: EMPTYSTRING)          // needed for isZero
    169     @State private var exchange: Exchange? = nil                                // wg. noFees and tosAccepted
    170 
    171     private func shortcutAction(_ shortcut: Amount) {
    172         amountShortcut = shortcut
    173         shortcutSelected = true
    174     }
    175     private func buttonAction() { buttonSelected = true }
    176 
    177     private func feeLabel(_ feeStr: String) -> String {
    178         feeStr.count > 0 ? String(localized: "- \(feeStr) fee")
    179                          : EMPTYSTRING
    180     }
    181 
    182     private func fee(raw: Amount, effective: Amount) -> Amount? {
    183         do {     // Incoming: fee = raw - effective
    184             let fee = try raw - effective
    185             return fee
    186         } catch {}
    187         return nil
    188     }
    189 
    190     private func feeIsNotZero() -> Bool? {
    191         if let hasNoFees = exchange?.noFees {
    192             if hasNoFees {
    193                 return nil      // this exchange never has fees
    194             }
    195         }
    196         return peerPullCheck != nil ? true : false
    197     }
    198 
    199     @MainActor
    200     private func computeFee(_ amount: Amount) async -> ComputeFeeResult? {
    201         if amount.isZero {
    202             return ComputeFeeResult.zero()
    203         }
    204             if exchange == nil {
    205                 if let url = balance.scopeInfo.url {
    206                     exchange = try? await model.getExchangeByUrl(url: url)
    207                 }
    208             }
    209             do {
    210                 let baseURL = exchange?.exchangeBaseUrl
    211                 let ppCheck = try await model.checkPeerPullCredit(amount, scope: balance.scopeInfo, viewHandles: true)
    212                 let raw = ppCheck.amountRaw
    213                 let effective = ppCheck.amountEffective
    214                 if let fee = fee(raw: raw, effective: effective) {
    215                     feeString = fee.formatted(balance.scopeInfo, isNegative: false)
    216                     symLog.log("Fee = \(feeString.0)")
    217 
    218                     peerPullCheck = ppCheck
    219                     let feeLabel = (feeLabel(feeString.0), feeLabel(feeString.1))
    220 //                    announce("\(amountVoiceOver), \(feeLabel)")
    221                     return ComputeFeeResult(insufficient: false,
    222                                                feeAmount: fee,
    223                                                   feeStr: feeLabel,             // TODO: feeLabelA11y
    224                                                 numCoins: ppCheck.numCoins)
    225                 } else {
    226                     peerPullCheck = nil
    227                 }
    228             } catch {
    229                 // handle cancel, errors
    230                 symLog.log("❗️ \(error), \(error.localizedDescription)")
    231                 switch error {
    232                     case let walletError as WalletBackendError:
    233                         switch walletError {
    234                             case .walletCoreError(let wError):
    235                                 if wError?.code == 7027 {
    236                                     return ComputeFeeResult.insufficient()
    237                                 }
    238                             default: break
    239                         }
    240                     default: break
    241                 }
    242             }
    243         return nil
    244     } // computeFee
    245 
    246     @MainActor
    247     private func newBalance() async {
    248         let scope = balance.scopeInfo
    249         symLog.log("❗️ task \(scope.currency)")
    250         let ppCheck = try? await model.checkPeerPullCredit(amountToTransfer, scope: scope, viewHandles: false)
    251         if let ppCheck {
    252             peerPullCheck = ppCheck
    253             var baseURL = ppCheck.scopeInfo?.url ?? ppCheck.exchangeBaseUrl
    254             if let baseURL {
    255                 if exchange == nil || exchange?.tosStatus != .accepted {
    256                     symLog.log("getExchangeByUrl(\(ppCheck.exchangeBaseUrl))")
    257                     exchange = try? await model.getExchangeByUrl(url: baseURL)
    258                 }
    259             }
    260         }
    261     }
    262 
    263     var body: some View {
    264 #if PRINT_CHANGES
    265         let _ = Self._printChanges()
    266         let _ = symLog.vlog()       // just to get the # to compare it with .onAppear & onDisappear
    267 #endif
    268         Group {
    269             let coinData = CoinData(details: peerPullCheck)
    270 //                let availableStr = amountAvailable.formatted(currencyInfo, isNegative: false)
    271 //                let amountVoiceOver = amountToTransfer.formatted(currencyInfo, isNegative: false)
    272                 let feeLabel = coinData.feeLabel(balance.scopeInfo,
    273                                         feeZero: String(localized: "No fee"),
    274                                      isNegative: false)
    275                 let inputDestination = P2PSubjectV(stack: stack.push(),
    276                                                     cash: cash,
    277                                                    scope: balance.scopeInfo,
    278                                                available: balance.available,
    279                                                 feeLabel: feeLabel,
    280                                             feeIsNotZero: feeIsNotZero(),
    281                                                 outgoing: false,
    282                                         amountToTransfer: $amountToTransfer,
    283                                                  summary: $summary,
    284                                                   iconID: $iconID,
    285                                               expireDays: $expireDays)
    286                 let shortcutDestination = P2PSubjectV(stack: stack.push(),
    287                                                        cash: cash,
    288                                                       scope: balance.scopeInfo,
    289                                                   available: balance.available,
    290                                                    feeLabel: nil,
    291                                                feeIsNotZero: feeIsNotZero(),
    292                                                    outgoing: false,
    293                                            amountToTransfer: $amountShortcut,
    294                                                     summary: $summary,
    295                                                      iconID: $iconID,
    296                                                  expireDays: $expireDays)
    297                 let actions = Group {
    298                     NavLink($buttonSelected) { inputDestination }
    299                     NavLink($shortcutSelected) { shortcutDestination }
    300                 }
    301                 let tosAccepted = (exchange?.tosStatus == .accepted) ?? false
    302                 if tosAccepted {
    303                     let a11yLabel = String(localized: "Amount to request:", comment: "a11y, no abbreviations")
    304                     let amountLabel = minimalistic ? String(localized: "Amount:")
    305                                                    : a11yLabel
    306                     AmountInputV(stack: stack.push(),
    307                                  scope: balance.scopeInfo,
    308                        amountAvailable: $amountZero,        // incoming needs no available
    309                            amountLabel: amountLabel,
    310                              a11yLabel: a11yLabel,
    311                       amountToTransfer: $amountToTransfer,
    312                         amountLastUsed: amountLastUsed,
    313                                wireFee: nil,
    314                                summary: $summary,
    315                         shortcutAction: shortcutAction,
    316                           buttonAction: buttonAction,
    317                             isIncoming: true,
    318                             computeFee: computeFee)
    319                     .background(actions)
    320                 } else {
    321                     if let peerPullCheck {
    322                         var baseURL = peerPullCheck.scopeInfo?.url ?? peerPullCheck.exchangeBaseUrl
    323                         ToSButtonView(stack: stack.push(),
    324                             exchangeBaseUrl: baseURL,
    325                                      viewID: VIEW_P2P_TOS,   // 31 WithdrawTOSView   TODO: YIKES might be withdraw-exchange
    326                                         p2p: false,
    327                                       acceptAction: nil)
    328                         .padding(.top)
    329                     } else {
    330                         Text("No baseURL")      // need $some view otherwise task will not run
    331                     }
    332                 }
    333         }
    334         .task(id: balance) { await newBalance() }
    335         .onAppear {
    336             DebugViewC.shared.setViewID(VIEW_P2P_REQUEST, stack: stack.push())
    337             symLog.log("❗️ onAppear")
    338         }
    339         .onDisappear {
    340             symLog.log("❗️ onDisappear")
    341         }
    342     } // body
    343 }
    344 // MARK: -
    345 #if DEBUG
    346 //struct ReceiveAmount_Previews: PreviewProvider {
    347 //    static var scopeInfo = ScopeInfo(type: ScopeInfo.ScopeInfoType.exchange, exchangeBaseUrl: DEMOEXCHANGE, currency: LONGCURRENCY)
    348 //    static var previews: some View {
    349 //        let amount = Amount(currency: LONGCURRENCY, integer: 10, fraction: 0)
    350 //        RequestPayment(exchangeBaseUrl: DEMOEXCHANGE, currency: LONGCURRENCY)
    351 //    }
    352 //}
    353 #endif