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