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