taler-ios

iOS apps for GNU Taler (wallet)
Log | Files | Refs | README | LICENSE

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