taler-ios

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

BankEditView.swift (13517B)


      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 /// This view shows the currency name in an exchange section
     13 ///         currency
     14 struct BankEditView: View {
     15     private let symLog = SymLogV(0)
     16     let stack: CallStack
     17     let accountID: String?
     18 
     19     @Environment(\.dismiss) var dismiss     // pop back once
     20     @EnvironmentObject private var model: WalletModel
     21     @EnvironmentObject private var controller: Controller
     22     @AppStorage("minimalistic") var minimalistic: Bool = false
     23     @AppStorage("demoHints") var demoHints: Bool = true
     24 //    @AppStorage("fakeNoFees") var fakeNoFees: Bool = true     TODO: use this flag!
     25     @AppStorage("ownerName") var ownerName: String = EMPTYSTRING
     26 
     27     @State private var shouldReloadBalances: Int = 0
     28     @State private var currencyInfo: CurrencyInfo = CurrencyInfo.zero(UNKNOWN)
     29     @State private var didDelete: Bool = false
     30     @State private var disabled: Bool = false
     31     @State private var showAlert: Bool = false
     32 
     33     @State private var myAccount: String? = nil
     34     @State private var accountHolder: String = EMPTYSTRING
     35     @State private var iban: String = EMPTYSTRING
     36     @State private var ibanValid = false
     37     @State private var xTaler: String = EMPTYSTRING
     38     @State private var accountLabel: String = EMPTYSTRING
     39     @State private var paytoType: PaytoType = .iban
     40     @State private var selected = 0
     41     @State private var kycCompleted = false
     42     @State private var finished = false
     43     @State private var unchanged = true
     44     @State private var userTyped = false
     45 
     46     @FocusState private var focus:FocusedField?
     47 
     48     enum FocusedField: Hashable {
     49         case accountLabel, accountHolder, iban, xTaler
     50     }
     51 
     52 //    @MainActor
     53 //    private func validateIban() async {
     54 //        if (try? await model.validateIban(iban)) == true {
     55 //            let payto = "payto://iban/\(iban)?receiver-name=\(accountHolder)"
     56 //            paytoUri = payto.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!
     57 //        } else {
     58 //            paytoUri = nil
     59 //        }
     60 //    }
     61 
     62     @ViewBuilder func barButton() -> some View {
     63         let isNew = accountID == nil
     64         let titleStr = isNew ? String(localized: "Add", comment: "button title: Add bank account")
     65                              : String(localized: "Update", comment: "button title: Update bank account")
     66         let a11yStr = isNew ? String(localized: "Add bank account", comment: "a11y")
     67                             : String(localized: "Update bank account", comment: "a11y")
     68         let notValid: Bool = paytoType == .iban ? (iban.isEmpty || !ibanValid)
     69                                                 : xTaler.isEmpty
     70         DoneButton(titleStr: titleStr, accessibilityLabelStr: titleStr) {
     71             Task {
     72                 if await updateAccount() {
     73                     dismiss()
     74                 }
     75             }
     76         }
     77             .disabled(disabled || notValid || unchanged)
     78     }
     79 
     80     @MainActor
     81     private func viewDidLoad() async {
     82         if let accountID {
     83             if let account = try? await model.getBankAccountById(accountID) {
     84                 accountLabel = account.label ?? EMPTYSTRING
     85                 kycCompleted = account.kycCompleted
     86                 let payTo = PayTo(account.paytoUri)
     87                 accountHolder = payTo.receiver ?? ownerName
     88                 xTaler = payTo.xTaler ?? EMPTYSTRING
     89                 iban = payTo.iban ?? EMPTYSTRING
     90                 if iban.isEmpty {
     91                     if xTaler.count > 1 {
     92                         paytoType = .xTalerBank
     93                     }
     94                 } else if let result = try? await model.validateIban(iban) {
     95                     ibanValid = result
     96                 }
     97             }
     98         } else {
     99 
    100         }
    101     }
    102 
    103     @MainActor
    104     private func updateAccount() async -> Bool {
    105         let isIBAN = paytoType == .iban
    106         if !accountHolder.isEmpty && !accountLabel.isEmpty {
    107             if isIBAN ? !iban.isEmpty
    108                       : !xTaler.isEmpty {
    109                 // Build the payto URI with URLComponents so each component is escaped as a
    110                 // component. The previous code percent-encoded the ALREADY-ASSEMBLED string
    111                 // with .urlQueryAllowed, which leaves "?", "&" and "=" untouched — an
    112                 // account holder called "a&foo=bar" injected extra query parameters.
    113 
    114                 // TODO: for x-taler-bank the target must be BANK-HOST/ACCOUNT. Accounts read
    115                 // from an existing payto now round-trip with their host, but there is still
    116                 // no separate input field for it, so a hand-typed account without a host
    117                 // produces an incomplete target. See bugs.txt [14.2].
    118                 var components = URLComponents()
    119                 components.scheme = "payto"
    120                 components.host = isIBAN ? "iban" : "x-taler-bank"
    121                 let target = isIBAN ? iban.filter { !$0.isWhitespace }.uppercased() : xTaler
    122                 components.path = "/" + target
    123                 components.queryItems = [URLQueryItem(name: "receiver-name", value: accountHolder)]
    124                 guard let paytoUri = components.string else {
    125                     symLog.log("could not build a payto URI from the entered account")
    126                     showAlert = true
    127                     return false
    128                 }
    129                 symLog.log(paytoUri)
    130                 if let account = try? await model.addBankAccount(paytoUri,
    131                                                            label: accountLabel,
    132                                                          replace: myAccount ?? accountID
    133                 ) {
    134                     symLog.log(account)
    135                     myAccount = account
    136                     return true
    137                 } else {
    138                     symLog.log("error addBankAccount")
    139                 }
    140             }
    141         }
    142         return false
    143     }
    144 
    145     @MainActor
    146     private func deleteAccount() {
    147         disabled = true     // don't try this more than once
    148         Task { // runs on MainActor
    149             if let id = myAccount ?? accountID {
    150                 if let _ = try? await model.forgetBankAccount(id) {
    151                     symLog.log("forgot \(id)")
    152                     didDelete = true             // change button text
    153 //                NotificationCenter.default.post(name: .ExchangeDeleted, object: nil, userInfo: nil)
    154 //                NotificationCenter.default.post(name: .BalanceChange, object: nil, userInfo: nil)
    155                     dismissTop(stack.push())
    156                 } else {
    157                     showAlert = true
    158                     disabled = false
    159                 }
    160             }
    161         }
    162     }
    163 
    164     var body: some View {
    165 #if PRINT_CHANGES
    166         let _ = Self._printChanges()
    167 //        let _ = symLog.vlog()       // just to get the # to compare it with .onAppear & onDisappear
    168 #endif
    169 
    170         let methods = [PaytoType.iban, PaytoType.xTalerBank]
    171         List {
    172             if kycCompleted // || true
    173             {   Section {
    174                     Text("If you change the account holder name or the IBAN, you may have to perform the legitimization procedure again.")          // TODO: BBAN
    175                         .talerFont(.body)
    176                 }
    177             }
    178             Section {
    179                 let labelTitle = String(localized: "Label")
    180                 let labelColon = String("\(labelTitle):")
    181                 if !minimalistic {
    182                     Text(labelColon)
    183                         .talerFont(.picker)
    184                         .accessibilityHidden(true)
    185                         .padding(.bottom, -12)
    186                 }
    187                 TextField(minimalistic ? labelTitle : EMPTYSTRING, text: $accountLabel)
    188                     .accessibilityLabel(labelColon)
    189                     .focused($focus, equals: .accountLabel)
    190                     .talerFont(.title3)
    191                     .foregroundColor(WalletColors().fieldForeground)     // text color
    192                     .background(WalletColors().fieldBackground)
    193                     .textFieldStyle(.roundedBorder)
    194                     .padding(.bottom)
    195                     .onChange(of: accountLabel) { newValue in
    196                         if userTyped {
    197                             unchanged = false
    198                         }
    199                     }
    200 
    201                 let holderTitle = String(localized: "Account holder")
    202                 let holderColon = String("\(holderTitle):")
    203                 if !minimalistic {
    204                     Text(holderColon)
    205                         .talerFont(.picker)
    206                         .accessibilityHidden(true)
    207                         .padding(.bottom, -12)
    208                 }
    209                 TextField(minimalistic ? holderTitle : EMPTYSTRING, text: $accountHolder)
    210                     .accessibilityLabel(holderColon)
    211                     .focused($focus, equals: .accountHolder)
    212                     .talerFont(.title3)
    213                     .foregroundColor(WalletColors().fieldForeground)     // text color
    214                     .background(WalletColors().fieldBackground)
    215                     .textFieldStyle(.roundedBorder)
    216                     .padding(.bottom)
    217                     .onChange(of: accountHolder) { newValue in
    218                         if userTyped {
    219                             unchanged = false
    220                         }
    221                     }
    222 
    223                 let paytoStr = paytoType.rawValue.uppercased()
    224                 let paytoColon = String("\(paytoStr):")
    225                 if let accountID {
    226                     // we are editing an existing bank account
    227                     Text(paytoColon)
    228                         .talerFont(.picker)
    229                         .accessibilityHidden(true)
    230                         .padding(.bottom, -12)
    231                 } else {
    232                     // this is a NEW bank account - choose a payment method
    233                     Picker(EMPTYSTRING, selection: $selected) {
    234                         ForEach(0..<methods.count, id: \.self) { index in
    235                             let method = methods[index]
    236                             Text(method.rawValue.uppercased())
    237                                 .tag(index)
    238                         }
    239                     }
    240                     .pickerStyle(.segmented)
    241                     .onChange(of: selected) { newValue in
    242                         paytoType = methods[newValue]
    243                     }
    244                 }
    245                 if paytoType == .iban {
    246                     TextField(paytoStr, text: $iban)            // TODO: BBAN
    247                         .accessibilityLabel(paytoColon)
    248                         .focused($focus, equals: .iban)
    249                         .talerFont(.title3)
    250                         .foregroundColor(WalletColors().fieldForeground)     // text color
    251                         .background(WalletColors().fieldBackground)
    252                         .textFieldStyle(.roundedBorder)
    253                         .padding(.bottom)
    254                         .onChange(of: iban) { newValue in
    255                             Task {
    256                                 if let result = try? await model.validateIban(newValue) {
    257                                     ibanValid = result
    258                                 }
    259                             }
    260                         }
    261                 } else if paytoType == .xTalerBank {
    262                     TextField(paytoStr, text: $xTaler)
    263                         .accessibilityLabel(paytoColon)
    264                         .focused($focus, equals: .xTaler)
    265                         .talerFont(.title3)
    266                         .foregroundColor(WalletColors().fieldForeground)     // text color
    267                         .background(WalletColors().fieldBackground)
    268                         .textFieldStyle(.roundedBorder)
    269                         .padding(.bottom)
    270                         .onChange(of: xTaler) { newValue in
    271                             if userTyped {
    272                                 unchanged = false
    273                             }
    274                         }
    275                 } else {
    276                     Text("unknown payment method")
    277                         .talerFont(.title3)
    278                         .padding(.bottom)
    279                 }
    280 
    281                 if let accountID {
    282                     let buttonTitle = String(localized: "Account.Delete", defaultValue: "Forget bank account", comment: "Action button")
    283                     let warningText1 = String(localized: "Are you sure you want to forget this bank account?")
    284                     WarningButton(warningText: warningText1,
    285                                   buttonTitle: buttonTitle,
    286                                    buttonIcon: "trash",
    287                                          role: .destructive,                            // TODO: WalletColors().errorColor
    288                                      disabled: $disabled,
    289                                        action: deleteAccount)
    290                         .padding(.top)
    291                 }
    292             }.listRowSeparator(.hidden)
    293         } // List
    294         .navigationBarItems(trailing: barButton())
    295 //        .ignoresSafeArea(.keyboard, edges: .bottom)
    296         .onChange(of: focus) { [focus] newState in
    297             switch newState {
    298                 case .none:
    299                     break
    300                 case .some(_):
    301                     userTyped = true
    302             }
    303         }
    304         .task { await viewDidLoad() }
    305         .onDisappear() {
    306             disabled = false
    307         }
    308     }
    309 }