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