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 }