P2PSubjectV.swift (9694B)
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 var maxExpirationHours: UInt64 { 97 if let duration = try? maxExpirationDate?.duration() { 98 return duration.toHours() 99 } 100 return 0 101 } 102 103 var body: some View { 104 #if PRINT_CHANGES 105 let _ = Self._printChanges() 106 let _ = symLog.vlog(amountToTransfer.readableDescription) // just to get the # to compare it with .onAppear & onDisappear 107 #endif 108 let scrollView = ScrollView { VStack (alignment: .leading, spacing: 6) { 109 if let feeIsNotZero { // don't show fee if nil 110 let label = feeLabel ?? myFeeLabel 111 if !label.0.isEmpty { 112 Text(label.0) 113 .accessibilityLabel(label.1) 114 .frame(maxWidth: .infinity, alignment: .trailing) 115 .foregroundColor(WalletColors().secondary(colorScheme, colorSchemeContrast)) 116 .talerFont(.body) 117 } 118 } 119 let enterSubject = String(localized: "Enter subject", comment: "Purpose, a11y") 120 let enterColon = String("\(enterSubject):") 121 if !minimalistic { 122 Text(enterSubject) // Purpose 123 .talerFont(.title3) 124 .accessibilityHidden(true) 125 .padding(.top) 126 } 127 Group { if #available(iOS 16.4, *) { 128 TextField(placeHolder, text: $summary, axis: .vertical) 129 } else { 130 TextField(placeHolder, text: $summary) 131 } } 132 .talerFont(.title2) 133 .accessibilityLabel(enterColon) 134 .submitLabel(.next) 135 .focused($isFocused) 136 .onChange(of: summary) { newValue in 137 guard isFocused else { return } 138 guard newValue.contains("\n") else { return } 139 isFocused = false 140 summary = newValue.replacingOccurrences(of: LINEFEED, with: EMPTYSTRING) 141 } 142 .foregroundColor(WalletColors().fieldForeground) // text color 143 .background(WalletColors().fieldBackground) 144 .textFieldStyle(.roundedBorder) 145 let numChars = summary.count 146 Text(verbatim: "\(numChars)/100") // maximum 100 characters 147 .frame(maxWidth: .infinity, alignment: .trailing) 148 .talerFont(.body) 149 .accessibilityLabel(EMPTYSTRING) 150 .accessibilityValue(String(localized: "\(numChars) characters of 100")) 151 152 // TODO: compute max Expiration day from peerPushCheck to disable 30 (and even 7) 153 SelectDays(selected: $expireDays, maxExpirationHours: maxExpirationHours, 154 defaultExpirationHours: defaultExpiration?.toHours() ?? 28*24, outgoing: outgoing) 155 .padding(.bottom) 156 157 let disabled = (expireDays == 0) // || (numChars < 1) // TODO: check amountAvailable 158 let destination = P2PReadyV(stack: stack.push(), 159 scope: scope, 160 summary: numChars > 0 ? summary : placeHolder, 161 iconID: iconID, 162 expireDays: expireDays, 163 outgoing: outgoing, 164 amountToTransfer: amountToTransfer, 165 transactionStarted: $transactionStarted) 166 let actions = Group { 167 NavLink($sendOrRequest) { destination } 168 } 169 170 let buttonTitle = buttonTitle(amountToTransfer) 171 Button(buttonTitle.0) { 172 sendOrRequest = true 173 } 174 .background(actions) 175 .accessibilityLabel(buttonTitle.1) 176 .buttonStyle(TalerButtonStyle(type: .prominent, disabled: disabled)) 177 .disabled(disabled) 178 .accessibilityHint(disabled ? String(localized: "enabled when subject and expiration are set", comment: "a11y") 179 : EMPTYSTRING) 180 }.padding(.horizontal) } // ScrollVStack 181 // .scrollBounceBehavior(.basedOnSize) needs iOS 16.4 182 // .ignoresSafeArea(.keyboard, edges: .bottom) 183 .navigationTitle(subjectTitle(amountToTransfer)) 184 .background(FullBackground()) 185 .task(id: amountToTransfer.value) { await checkPeerPushDebit() } 186 .onAppear { 187 DebugViewC.shared.setViewID(VIEW_P2P_SUBJECT, stack: stack.push()) 188 // print("❗️ P2PSubjectV onAppear") 189 } 190 .onDisappear { 191 // print("❗️ P2PSubjectV onDisappear") 192 } 193 194 scrollView 195 #if OIM 196 .overlay { if #available(iOS 16.4, *) { 197 if controller.oimModeActive { 198 OIMSubjectView(stack: stack.push(), 199 cash: cash, 200 available: available, 201 amountToTransfer: $amountToTransfer, 202 selectedGoal: $iconID, 203 fwdButtonTapped: $sendOrRequest) 204 .environmentObject(NamespaceWrapper(namespace)) // keep OIMviews apart 205 } 206 } } 207 #endif 208 } 209 } 210 // MARK: - 211 #if DEBUG 212 //struct SendPurpose_Previews: PreviewProvider { 213 // static var previews: some View { 214 // @State var summary: String = EMPTYSTRING 215 // @State var expireDays: UInt = 0 216 // let amount = Amount(currency: LONGCURRENCY, integer: 10, fraction: 0) 217 // SendPurpose(amountAvailable: amount, 218 // amountToTransfer: 543, 219 // fee: "0,43", 220 // summary: $summary, 221 // expireDays: $expireDays) 222 // } 223 //} 224 #endif