taler-ios

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

RequestPayment.swift (14780B)


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