AmountInputV.swift (6418B)
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 struct ComputeFeeResult { 13 let insufficient: Bool 14 let feeAmount: Amount? 15 let feeStr: (String, String) 16 let numCoins: Int? 17 18 static func zero() -> ComputeFeeResult { 19 ComputeFeeResult(insufficient: false, 20 feeAmount: nil, 21 feeStr: (EMPTYSTRING, EMPTYSTRING), 22 numCoins: 0) 23 } 24 static func insufficient() -> ComputeFeeResult { 25 ComputeFeeResult(insufficient: true, 26 feeAmount: nil, 27 feeStr: (EMPTYSTRING, EMPTYSTRING), 28 numCoins: -1) 29 } 30 } 31 // MARK: - 32 struct AmountInputV: View { 33 private let symLog = SymLogV(0) 34 let stack: CallStack 35 let scope: ScopeInfo? 36 @Binding var amountAvailable: Amount 37 let amountLabel: String? 38 let a11yLabel: String 39 @Binding var amountToTransfer: Amount 40 let amountLastUsed: Amount 41 let wireFee: Amount? 42 @Binding var summary: String 43 // @Binding var insufficient: Bool 44 // @Binding var feeAmount: Amount? 45 let shortcutAction: ((_ amount: Amount) -> Void)? 46 let buttonAction: () -> Void 47 // let feeIsNegative: Bool 48 let isIncoming: Bool 49 let computeFee: ((_ amount: Amount) async -> ComputeFeeResult?)? 50 51 @EnvironmentObject private var controller: Controller 52 @Environment(\.colorScheme) private var colorScheme 53 @Environment(\.colorSchemeContrast) private var colorSchemeContrast 54 #if DEBUG 55 @AppStorage("developerMode") var developerMode: Bool = true 56 #else 57 @AppStorage("developerMode") var developerMode: Bool = false 58 #endif 59 60 @State private var feeAmount: Amount? = nil 61 @State private var feeStr = (EMPTYSTRING, EMPTYSTRING) 62 @State private var numCoins: Int? 63 @State private var coreInsufficient: Bool = false // wallet-core's own verdict 64 65 struct Flags { 66 let insufficient: Bool 67 let disabled: Bool 68 } 69 70 func checkAvailable(_ coinData: CoinData) -> Flags { 71 let isZero = amountToTransfer.isZero && !developerMode 72 // wallet-core's verdict is authoritative and comes first: it accounts for fees, 73 // age restrictions, per-exchange limits and pending refreshes, none of which a 74 // local `amountToTransfer > amountAvailable` can see. 75 if coreInsufficient { 76 return Flags(insufficient: true, disabled: true) 77 } 78 if !amountAvailable.isZero { 79 do { 80 let insufficient: Bool 81 // if let feeAmount { 82 // if feeIsNegative { 83 // insufficient = try amountToTransfer > amountAvailable 84 // } else { 85 // insufficient = try (amountToTransfer + feeAmount) > amountAvailable 86 // } 87 // } else { 88 insufficient = try amountToTransfer > amountAvailable 89 // } 90 let invalid = coinData.invalid && !developerMode // P2P ==> zero not supported 91 let disabled = insufficient || isZero || invalid || coinData.tooMany 92 return Flags(insufficient: insufficient, disabled: disabled) 93 } catch { 94 // TODO: error Amounts don't match 95 symLog.log("❗️Cannot compare amountAvailable.\(amountAvailable.currencyStr) to amountToTransfer.\(amountToTransfer.currencyStr))") 96 } 97 } 98 // No usable local bound (an incoming amount, or a caller that never sets 99 // amountAvailable — see PayTemplateScan.swift:36). Deliberately NOT failing closed 100 // here: those callers still get the wallet-core verdict above, and failing closed 101 // would permanently disable a pay-template that has no availability to compare to. 102 return Flags(insufficient: false, disabled: isZero || coinData.invalid || coinData.tooMany) 103 } 104 105 var body: some View { 106 let currency = amountToTransfer.currencyStr 107 let coinData = CoinData(coins: numCoins, fee: feeAmount) 108 let flags = checkAvailable(coinData) 109 let hint = String(localized: "enabled when amount is non-zero", comment: "a11y") 110 ScrollView { VStack(alignment: .trailing) { 111 CurrencyInputView(scope: scope, 112 amount: $amountToTransfer, 113 amountLastUsed: amountLastUsed, 114 available: isIncoming ? nil : amountAvailable, 115 insufficient: flags.insufficient, 116 title: amountLabel, 117 a11yTitle: a11yLabel, 118 shortcutAction: shortcutAction) 119 // .accessibility(sortPriority: 2) 120 QuiteSomeCoins(scope: scope, 121 coinData: coinData, 122 shouldShowFee: true, // TODO: set to false if we never charge withdrawal fees 123 feeIsNegative: isIncoming) 124 Button("Next") { buttonAction() } 125 .buttonStyle(TalerButtonStyle(type: .prominent, disabled: flags.disabled)) 126 .disabled(flags.disabled) 127 .accessibilityHint(flags.disabled ? hint : EMPTYSTRING) 128 .padding(.bottom, 333) 129 }.padding(.horizontal) } // ScrollVStack 130 .frame(maxWidth: .infinity, alignment: .leading) 131 .background(FullBackground()) 132 .task(id: amountToTransfer.value) { 133 // re-compute the fees on every tapped digit or backspace 134 if let computeFee { 135 symLog.log(".task \(amountToTransfer.value)") 136 if let result: ComputeFeeResult = await computeFee(amountToTransfer) { 137 symLog.log("computeFee() finished") 138 feeStr = result.feeStr 139 coreInsufficient = result.insufficient 140 feeAmount = result.feeAmount 141 numCoins = result.numCoins 142 } else { 143 // No verdict this round - keep the previous one rather than silently 144 // treating an unanswered question as "you can afford it". 145 symLog.log("computeFee() failed ❗️") // \(error)") 146 } 147 } 148 } 149 } 150 }