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