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