P2PSubjectV.swift (10399B)
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 func p2pFee(ppCheck: CheckPeerPushDebitResponse) -> Amount? { 13 do { 14 // Outgoing: fee = effective - raw 15 if let effective = ppCheck.amountEffective, 16 let raw = ppCheck.amountRaw { 17 let fee = try effective - raw 18 return fee 19 } 20 } catch {} 21 return nil 22 } 23 24 struct P2PSubjectV: View { 25 private let symLog = SymLogV(0) 26 let stack: CallStack 27 let cash: OIMcash 28 let scope: ScopeInfo 29 let available: Amount 30 let feeLabel: (String, String)? 31 let feeIsNotZero: Bool? // nil = no fees at all, false = no fee for this tx 32 let outgoing: Bool 33 @Binding var amountToTransfer: Amount 34 @Binding var summary: String 35 @Binding var iconID: String? 36 @Binding var expireDays: UInt 37 38 @EnvironmentObject private var controller: Controller 39 @EnvironmentObject private var model: WalletModel 40 @Environment(\.colorScheme) private var colorScheme 41 @Environment(\.colorSchemeContrast) private var colorSchemeContrast 42 @AppStorage("minimalistic") var minimalistic: Bool = false 43 44 @State private var myFeeLabel: (String, String) = (EMPTYSTRING, EMPTYSTRING) 45 @State private var defaultExpiration: Duration? = nil 46 @State private var maxExpirationDate: Timestamp? = nil 47 @State private var transactionStarted: Bool = false 48 @State private var sendOrRequest: Bool = false 49 @FocusState private var isFocused: Bool 50 @Namespace var namespace 51 52 private func sendTitle(_ amountWithCurrency: String) -> String { 53 String(localized: "Send \(amountWithCurrency) now", 54 comment: "amount with currency") 55 } 56 private func requTitle(_ amountWithCurrency: String) -> String { 57 String(localized: "Request \(amountWithCurrency)", 58 comment: "amount with currency") 59 } 60 61 private func buttonTitle(_ amount: Amount) -> (String, String) { 62 let amountWithCurrency = amount.formatted(scope, isNegative: false, useISO: true) 63 return outgoing ? (sendTitle(amountWithCurrency.0), sendTitle(amountWithCurrency.1)) 64 : (requTitle(amountWithCurrency.0), requTitle(amountWithCurrency.1)) 65 } 66 67 private var placeHolder: String { 68 return outgoing ? String(localized: "Sent with GNU TALER") 69 : String(localized: "Requested with GNU TALER") 70 } 71 72 private func subjectTitle(_ amount: Amount) -> String { 73 let amountStr = amount.formatted(scope, isNegative: false) 74 return outgoing ? String(localized: "NavTitle_Send_AmountStr", 75 defaultValue: "Send \(amountStr.0)", 76 comment: "NavTitle: Send 'amountStr'") 77 : String(localized: "NavTitle_Request_AmountStr", 78 defaultValue: "Request \(amountStr.0)", 79 comment: "NavTitle: Request 'amountStr'") 80 } 81 82 @MainActor 83 private func checkPeerPushDebit() async { 84 // Needed even when the caller already computed the fee (feeLabel != nil): SelectDays 85 // tells the user defaultExpiration, and that is what wallet-core applies when P2PReadyV 86 // sends purse_expiration nil. The caller has already shown errors of this check. 87 if outgoing { 88 if let ppCheck = try? await model.checkPeerPushDebit(amountToTransfer, scope: scope, 89 viewHandles: feeLabel != nil) { 90 if feeLabel == nil { 91 if let feeAmount = p2pFee(ppCheck: ppCheck) { 92 let feeStr = feeAmount.formatted(scope, isNegative: false) 93 myFeeLabel = (String(localized: "+ \(feeStr.0) fee"), 94 String(localized: "+ \(feeStr.1) fee")) 95 } else { myFeeLabel = (EMPTYSTRING, EMPTYSTRING) } 96 } 97 defaultExpiration = ppCheck.defaultExpiration 98 maxExpirationDate = ppCheck.maxExpirationDate 99 } else { 100 print("❗️ checkPeerPushDebitM failed") 101 } 102 } 103 } 104 105 var maxExpirationHours: UInt { 106 if let duration = try? maxExpirationDate?.duration() { 107 return UInt(duration.toHours()) 108 } 109 return 0 110 } 111 var defaultExpirationHours: UInt { 112 if let defaultExpiration { 113 return UInt(defaultExpiration.toHours()) 114 } 115 return UInt(DEFAULTEXPIRATION) 116 } 117 var body: some View { 118 #if PRINT_CHANGES 119 let _ = Self._printChanges() 120 let _ = symLog.vlog(amountToTransfer.readableDescription) // just to get the # to compare it with .onAppear & onDisappear 121 #endif 122 let scrollView = ScrollView { VStack (alignment: .leading, spacing: 6) { 123 if let feeIsNotZero { // don't show fee if nil 124 let label = feeLabel ?? myFeeLabel 125 if !label.0.isEmpty { 126 Text(label.0) 127 .accessibilityLabel(label.1) 128 .frame(maxWidth: .infinity, alignment: .trailing) 129 .foregroundColor(WalletColors().secondary2(colorScheme, colorSchemeContrast)) 130 .talerFont(.body) 131 } 132 } 133 let enterSubject = String(localized: "Enter subject", comment: "Purpose, a11y") 134 let enterColon = String("\(enterSubject):") 135 if !minimalistic { 136 Text(enterSubject) // Purpose 137 .talerFont(.title3) 138 .accessibilityHidden(true) 139 .padding(.top) 140 } 141 Group { if #available(iOS 16.4, *) { 142 TextField(placeHolder, text: $summary, axis: .vertical) 143 } else { 144 TextField(placeHolder, text: $summary) 145 } } 146 .talerFont(.title2) 147 .accessibilityLabel(enterColon) 148 .submitLabel(.next) 149 .focused($isFocused) 150 .onChange(of: summary) { newValue in 151 guard isFocused else { return } 152 guard newValue.contains(LINEFEED) else { return } 153 isFocused = false 154 summary = newValue.replacingOccurrences(of: LINEFEED, with: EMPTYSTRING) 155 } 156 .foregroundColor(WalletColors().fieldForeground) // text color 157 .background(WalletColors().fieldBackground) 158 .textFieldStyle(.roundedBorder) 159 let numChars = summary.count 160 Text(verbatim: "\(numChars)/100") // maximum 100 characters 161 .frame(maxWidth: .infinity, alignment: .trailing) 162 .talerFont(.body) 163 .accessibilityLabel(EMPTYSTRING) 164 .accessibilityValue(String(localized: "\(numChars) characters of 100")) 165 166 // TODO: compute max Expiration day from peerPushCheck to disable 30 (and even 7) 167 SelectDays(selected: $expireDays, maxExpirationHours: maxExpirationHours, 168 defaultExpirationHours: defaultExpirationHours, outgoing: outgoing) 169 .padding(.bottom) 170 171 let disabled = (expireDays == 0) // || (numChars < 1) // TODO: check amountAvailable 172 let destination = P2PReadyV(stack: stack.push(), 173 scope: scope, 174 summary: numChars > 0 ? summary : placeHolder, 175 iconID: iconID, 176 expireDays: expireDays, 177 outgoing: outgoing, 178 amountToTransfer: amountToTransfer, 179 transactionStarted: $transactionStarted) 180 let actions = Group { 181 NavLink($sendOrRequest) { destination } 182 } 183 184 let buttonTitle = buttonTitle(amountToTransfer) 185 Button(buttonTitle.0) { 186 sendOrRequest = true 187 } 188 .background(actions) 189 .accessibilityLabel(buttonTitle.1) 190 .buttonStyle(TalerButtonStyle(type: .prominent, disabled: disabled)) 191 .disabled(disabled) 192 .accessibilityHint(disabled ? String(localized: "enabled when subject and expiration are set", comment: "a11y") 193 : EMPTYSTRING) 194 }.padding(.horizontal) } // ScrollVStack 195 // .scrollBounceBehavior(.basedOnSize) needs iOS 16.4 196 // .ignoresSafeArea(.keyboard, edges: .bottom) 197 .navigationTitle(subjectTitle(amountToTransfer)) 198 .background(FullBackground()) 199 .task(id: amountToTransfer.value) { await checkPeerPushDebit() } 200 .onAppear { 201 DebugViewC.shared.setViewID(VIEW_P2P_SUBJECT, stack: stack.push()) 202 // print("❗️ P2PSubjectV onAppear") 203 } 204 .onDisappear { 205 // print("❗️ P2PSubjectV onDisappear") 206 } 207 208 scrollView 209 #if OIM 210 .overlay { if #available(iOS 16.4, *) { 211 if controller.oimModeActive { 212 OIMSubjectView(stack: stack.push(), 213 cash: cash, 214 available: available, 215 amountToTransfer: $amountToTransfer, 216 selectedGoal: $iconID, 217 fwdButtonTapped: $sendOrRequest) 218 .environmentObject(NamespaceWrapper(namespace)) // keep OIMviews apart 219 } 220 } } 221 #endif 222 } 223 } 224 // MARK: - 225 #if DEBUG 226 //struct SendPurpose_Previews: PreviewProvider { 227 // static var previews: some View { 228 // @State private var summary: String = EMPTYSTRING 229 // @State private var expireDays: UInt = 0 230 // let amount = Amount(currency: LONGCURRENCY, integer: 10, fraction: 0) 231 // SendPurpose(amountAvailable: amount, 232 // amountToTransfer: 543, 233 // fee: "0,43", 234 // summary: $summary, 235 // expireDays: $expireDays) 236 // } 237 //} 238 #endif