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