diff --git a/Packages/KeyAppKit/Sources/BankTransfer/Repository/BankTransferUserDataRepository.swift b/Packages/KeyAppKit/Sources/BankTransfer/Repository/BankTransferUserDataRepository.swift index 767ab9b4d4..e0c2345d33 100644 --- a/Packages/KeyAppKit/Sources/BankTransfer/Repository/BankTransferUserDataRepository.swift +++ b/Packages/KeyAppKit/Sources/BankTransfer/Repository/BankTransferUserDataRepository.swift @@ -1,4 +1,6 @@ public protocol BankTransferUserDataRepository { + associatedtype WithdrawalInfo + func getUserId() async -> String? func getKYCStatus() async throws -> StrigaKYC @@ -17,4 +19,7 @@ public protocol BankTransferUserDataRepository { func clearCache() async func getWallet(userId: String) async throws -> UserWallet? + + func withdrawalInfo() async throws -> WithdrawalInfo? + func save(_ info: WithdrawalInfo) async throws } diff --git a/Packages/KeyAppKit/Sources/BankTransfer/Service/BankTransferService.swift b/Packages/KeyAppKit/Sources/BankTransfer/Service/BankTransferService.swift index 507fc6fd0a..bc00e1a324 100644 --- a/Packages/KeyAppKit/Sources/BankTransfer/Service/BankTransferService.swift +++ b/Packages/KeyAppKit/Sources/BankTransfer/Service/BankTransferService.swift @@ -3,6 +3,7 @@ import KeyAppKitCore public protocol BankTransferService where Provider: BankTransferUserDataRepository { associatedtype Provider + typealias WithdrawalInfo = Provider.WithdrawalInfo var state: AnyPublisher, Never> { get } @@ -22,6 +23,10 @@ public protocol BankTransferService where Provider: BankTransferUserDa func resendSMS() async throws func getKYCToken() async throws -> String + + // WithdrowalProvider + func withdrawalInfo() async throws -> WithdrawalInfo? + func saveWithdrawalInfo(info: WithdrawalInfo) async throws } public class AnyBankTransferService { diff --git a/Packages/KeyAppKit/Sources/BankTransfer/Service/BankTransferServiceImpl.swift b/Packages/KeyAppKit/Sources/BankTransfer/Service/BankTransferServiceImpl.swift index f057e194be..792c916dda 100644 --- a/Packages/KeyAppKit/Sources/BankTransfer/Service/BankTransferServiceImpl.swift +++ b/Packages/KeyAppKit/Sources/BankTransfer/Service/BankTransferServiceImpl.swift @@ -168,4 +168,13 @@ extension BankTransferServiceImpl { ) ) } + + public func withdrawalInfo() async throws -> Provider.WithdrawalInfo? { + try await repository.withdrawalInfo() + + } + + public func saveWithdrawalInfo(info: Provider.WithdrawalInfo) async throws { + try await repository.save(info) + } } diff --git a/Packages/KeyAppKit/Sources/BankTransfer/Striga/Mock/MockStrigaLocalProvider.swift b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Mock/MockStrigaLocalProvider.swift index d95cd2c3e3..c2135b613d 100644 --- a/Packages/KeyAppKit/Sources/BankTransfer/Striga/Mock/MockStrigaLocalProvider.swift +++ b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Mock/MockStrigaLocalProvider.swift @@ -88,6 +88,14 @@ public actor MockStrigaLocalProvider: StrigaLocalProvider { fatalError() } + public func getCachedWithdrawalInfo() async -> StrigaWithdrawalInfo? { + fatalError() + } + + public func save(withdrawalInfo: StrigaWithdrawalInfo) async throws { + fatalError() + } + public func clear() async { self.cachedRegistrationData = nil self.useCase = .unregisteredUser diff --git a/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaWithdrawalInfo.swift b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaWithdrawalInfo.swift new file mode 100644 index 0000000000..a9e30550bc --- /dev/null +++ b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaWithdrawalInfo.swift @@ -0,0 +1,13 @@ +import Foundation + +public struct StrigaWithdrawalInfo: Codable { + public var IBAN: String? + public var BIC: String? + public var receiver: String + + public init(IBAN: String? = nil, BIC: String? = nil, receiver: String) { + self.IBAN = IBAN + self.BIC = BIC + self.receiver = receiver + } +} diff --git a/Packages/KeyAppKit/Sources/BankTransfer/Striga/Repository/DataProviders/StrigaLocalProvider.swift b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Repository/DataProviders/StrigaLocalProvider.swift index 46e9f57371..0c61c3a926 100644 --- a/Packages/KeyAppKit/Sources/BankTransfer/Striga/Repository/DataProviders/StrigaLocalProvider.swift +++ b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Repository/DataProviders/StrigaLocalProvider.swift @@ -10,6 +10,9 @@ public protocol StrigaLocalProvider { func getWhitelistedUserDestinations() async throws -> [StrigaWhitelistAddressResponse] func save(whitelisted: [StrigaWhitelistAddressResponse]) async throws + func getCachedWithdrawalInfo() async -> StrigaWithdrawalInfo? + func save(withdrawalInfo: StrigaWithdrawalInfo) async throws + func clear() async } @@ -39,6 +42,14 @@ public actor StrigaLocalProviderImpl { extension StrigaLocalProviderImpl: StrigaLocalProvider { + public func getCachedWithdrawalInfo() async -> StrigaWithdrawalInfo? { + return get(from: cacheFileFor(.withdrawalInfo)) + } + + public func save(withdrawalInfo: StrigaWithdrawalInfo) async throws { + try await save(model: withdrawalInfo, in: cacheFileFor(.withdrawalInfo)) + } + public func getCachedRegistrationData() -> StrigaUserDetailsResponse? { return get(from: cacheFileFor(.registration)) } @@ -88,6 +99,8 @@ extension StrigaLocalProviderImpl: StrigaLocalProvider { case registration case account case whitelisted + case withdrawalInfo + } private func cacheFileFor(_ name: CacheFileName) -> URL { diff --git a/Packages/KeyAppKit/Sources/BankTransfer/Striga/Repository/StrigaBankTransferUserDataRepository.swift b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Repository/StrigaBankTransferUserDataRepository.swift index 9853387eb2..13c121bf4b 100644 --- a/Packages/KeyAppKit/Sources/BankTransfer/Striga/Repository/StrigaBankTransferUserDataRepository.swift +++ b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Repository/StrigaBankTransferUserDataRepository.swift @@ -7,7 +7,7 @@ import KeyAppKitCore import KeyAppKitLogger public final class StrigaBankTransferUserDataRepository: BankTransferUserDataRepository { - + public typealias WithdrawalInfo = StrigaWithdrawalInfo // MARK: - Properties private let localProvider: StrigaLocalProvider @@ -412,3 +412,21 @@ private extension UserWallet { self.accounts = UserAccounts(eur: eur, usdc: usdc) } } + +extension StrigaBankTransferUserDataRepository { + public func withdrawalInfo() async throws -> WithdrawalInfo? { + await localProvider.getCachedWithdrawalInfo()// ?? + /// GetAccountStatement here +// WithdrawalInfo(IBAN: "IBAN", BIC: "BIC", receiver: "Receiver") + } + + public func save(_ info: StrigaWithdrawalInfo) async throws { + try await localProvider.save( + withdrawalInfo: .init( + IBAN: info.IBAN, + BIC: info.BIC, + receiver: info.receiver + ) + ) + } +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/Claim/BankTransferClaimCoordinator.swift b/p2p_wallet/Scenes/Main/BankTransfer/Claim/BankTransferClaimCoordinator.swift index 60321cf5e7..09098911ea 100644 --- a/p2p_wallet/Scenes/Main/BankTransfer/Claim/BankTransferClaimCoordinator.swift +++ b/p2p_wallet/Scenes/Main/BankTransfer/Claim/BankTransferClaimCoordinator.swift @@ -42,6 +42,8 @@ final class BankTransferClaimCoordinator: Coordinator AnyPublisher { // Start OTP Coordinator bankTransferService.value.state + .prefix(1) + .receive(on: RunLoop.main) .flatMap { [unowned self] state in guard let phone = state.value.mobileNumber else { return Just(StrigaOTPCoordinatorResult.canceled) @@ -56,13 +58,14 @@ final class BankTransferClaimCoordinator: Coordinator String { + // get transaction from proxy api + + // sign transaction + + // TODO: - send to blockchain + try? await Task.sleep(seconds: 1) + return .fakeTransactionSignature(id: UUID().uuidString) + } +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/FirstStep/StrigaRegistrationFirstStepView.swift b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/FirstStep/StrigaRegistrationFirstStepView.swift index 3043ec236a..2a540601ee 100644 --- a/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/FirstStep/StrigaRegistrationFirstStepView.swift +++ b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/FirstStep/StrigaRegistrationFirstStepView.swift @@ -3,7 +3,7 @@ import KeyAppUI import CountriesAPI fileprivate typealias TextField = StrigaRegistrationTextField -fileprivate typealias Cell = StrigaRegistrationCell +fileprivate typealias Cell = StrigaFormCell fileprivate typealias DetailedButton = StrigaRegistrationDetailedButton struct StrigaRegistrationFirstStepView: View { diff --git a/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/FirstStep/StrigaRegistrationFirstStepViewModel.swift b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/FirstStep/StrigaRegistrationFirstStepViewModel.swift index b718c2d5c2..e715a98bf9 100644 --- a/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/FirstStep/StrigaRegistrationFirstStepViewModel.swift +++ b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/FirstStep/StrigaRegistrationFirstStepViewModel.swift @@ -40,7 +40,7 @@ final class StrigaRegistrationFirstStepViewModel: BaseViewModel, ObservableObjec let choosePhoneCountryCode = PassthroughSubject() let back = PassthroughSubject() - var fieldsStatuses = [StrigaRegistrationField: StrigaRegistrationTextFieldStatus]() + var fieldsStatuses = [StrigaRegistrationField: StrigaFormTextFieldStatus]() @Published var selectedCountryOfBirth: Country? @Published private var dateOfBirthModel: StrigaUserDetailsResponse.DateOfBirth? diff --git a/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/SecondStep/StrigaRegistrationSecondStepView.swift b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/SecondStep/StrigaRegistrationSecondStepView.swift index ac12da6e11..df033d87ad 100644 --- a/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/SecondStep/StrigaRegistrationSecondStepView.swift +++ b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/SecondStep/StrigaRegistrationSecondStepView.swift @@ -2,7 +2,7 @@ import SwiftUI import KeyAppUI fileprivate typealias TextField = StrigaRegistrationTextField -fileprivate typealias Cell = StrigaRegistrationCell +fileprivate typealias Cell = StrigaFormCell fileprivate typealias DetailedButton = StrigaRegistrationDetailedButton struct StrigaRegistrationSecondStepView: View { diff --git a/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/SecondStep/StrigaRegistrationSecondStepViewModel.swift b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/SecondStep/StrigaRegistrationSecondStepViewModel.swift index 6f880cc97a..1b4973d47e 100644 --- a/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/SecondStep/StrigaRegistrationSecondStepViewModel.swift +++ b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/SecondStep/StrigaRegistrationSecondStepViewModel.swift @@ -41,7 +41,7 @@ final class StrigaRegistrationSecondStepViewModel: BaseViewModel, ObservableObje let chooseCountry = PassthroughSubject() let openHardError = PassthroughSubject() - var fieldsStatuses = [StrigaRegistrationField: StrigaRegistrationTextFieldStatus]() + var fieldsStatuses = [StrigaRegistrationField: StrigaFormTextFieldStatus]() @Published var selectedCountry: Country? @Published var selectedIndustry: Industry? diff --git a/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/Subviews/StrigaRegistrationTextField.swift b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/Subviews/StrigaRegistrationTextField.swift index 9c5036d3d9..4eb81099db 100644 --- a/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/Subviews/StrigaRegistrationTextField.swift +++ b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/Subviews/StrigaRegistrationTextField.swift @@ -1,25 +1,25 @@ import SwiftUI import KeyAppUI -struct StrigaRegistrationTextField: View { +struct StrigaRegistrationTextField: View { - let field: StrigaRegistrationField + let field: TextFieldType let placeholder: String let isEnabled: Bool let onSubmit: () -> Void let submitLabel: SubmitLabel @Binding var text: String - @Binding var focus: StrigaRegistrationField? + @Binding var focus: TextFieldType? - @FocusState private var isFocused: StrigaRegistrationField? + @FocusState private var isFocused: TextFieldType? init( - field: StrigaRegistrationField, + field: TextFieldType, placeholder: String, text: Binding, isEnabled: Bool = true, - focus: Binding, + focus: Binding, onSubmit: @escaping () -> Void, submitLabel: SubmitLabel ) { @@ -58,8 +58,8 @@ struct StrigaRegistrationTextField: View { struct StrigaRegistrationTextField_Previews: PreviewProvider { static var previews: some View { VStack { - StrigaRegistrationTextField( - field: .email, + StrigaRegistrationTextField( + field: StrigaRegistrationField.email, placeholder: "Enter email", text: .constant(""), focus: .constant(nil), @@ -67,8 +67,8 @@ struct StrigaRegistrationTextField_Previews: PreviewProvider { submitLabel: .next ) - StrigaRegistrationTextField( - field: .phoneNumber, + StrigaRegistrationTextField( + field: StrigaRegistrationField.phoneNumber, placeholder: "Enter phone", text: .constant(""), focus: .constant(nil), diff --git a/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/Subviews/StringRegistrationCell.swift b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/Subviews/StringRegistrationCell.swift index adbd50a1e1..3dc20e52e1 100644 --- a/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/Subviews/StringRegistrationCell.swift +++ b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/Subviews/StringRegistrationCell.swift @@ -1,19 +1,19 @@ import SwiftUI import KeyAppUI -enum StrigaRegistrationTextFieldStatus: Equatable { +enum StrigaFormTextFieldStatus: Equatable { case valid case invalid(error: String) } -struct StrigaRegistrationCell: View { +struct StrigaFormCell: View { let title: String - let status: StrigaRegistrationTextFieldStatus + let status: StrigaFormTextFieldStatus @ViewBuilder private var content: () -> Content init( title: String, - status: StrigaRegistrationTextFieldStatus? = .valid, + status: StrigaFormTextFieldStatus? = .valid, @ViewBuilder content: @escaping () -> Content ) { self.title = title diff --git a/p2p_wallet/Scenes/Main/BankTransfer/Withdraw/WithdrawCoordinator.swift b/p2p_wallet/Scenes/Main/BankTransfer/Withdraw/WithdrawCoordinator.swift new file mode 100644 index 0000000000..449447a9f8 --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/Withdraw/WithdrawCoordinator.swift @@ -0,0 +1,63 @@ +import Combine +import BankTransfer +import Foundation +import Resolver +import SwiftUI + +final class WithdrawCoordinator: Coordinator { + + let navigationController: UINavigationController + let strategy: Strategy + let withdrawalInfo: StrigaWithdrawalInfo + + init( + navigationController: UINavigationController, + strategy: Strategy = .gathering, + withdrawalInfo: StrigaWithdrawalInfo + ) { + self.navigationController = navigationController + self.strategy = strategy + self.withdrawalInfo = withdrawalInfo + super.init() + } + + override func start() -> AnyPublisher { + let viewModel = WithdrawViewModel( + withdrawalInfo: StrigaWithdrawalInfo( + IBAN: withdrawalInfo.BIC, + BIC: withdrawalInfo.IBAN, + receiver: withdrawalInfo.receiver + ) + ) + let view = WithdrawView( + viewModel: viewModel + ) + let viewController = UIHostingController(rootView: view) + viewController.hidesBottomBarWhenPushed = true + navigationController.pushViewController(viewController, animated: true) + return Publishers.Merge( + viewController.deallocatedPublisher() + .map { WithdrawCoordinator.Result.canceled }, + viewModel.actionCompletedPublisher + .map { WithdrawCoordinator.Result.verified } + .handleEvents(receiveOutput: { _ in + self.navigationController.popViewController(animated: true) + }) + ) + .prefix(1).eraseToAnyPublisher() + } +} + +extension WithdrawCoordinator { + enum Result { + case verified + case canceled + } + + enum Strategy { + /// Used to collect IBAN + case gathering + /// Used to confirm withdrawal + case confirmation + } +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/Withdraw/WithdrawView.swift b/p2p_wallet/Scenes/Main/BankTransfer/Withdraw/WithdrawView.swift new file mode 100644 index 0000000000..2c938d49a1 --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/Withdraw/WithdrawView.swift @@ -0,0 +1,128 @@ +import SwiftUI +import Resolver +import BankTransfer +import Combine +import KeyAppUI + +struct WithdrawView: View { + @ObservedObject var viewModel: WithdrawViewModel + @State private var focus: WithdrawViewField? + + var body: some View { + ColoredBackground { + VStack { + ScrollView { + form + .animation(.spring(blendDuration: 0.01), value: viewModel.fieldsStatuses) + } + + Spacer() + + NewTextButton( + title: viewModel.actionTitle.uppercaseFirst, + style: .primaryWhite, + expandable: true, + isEnabled: true, + isLoading: viewModel.isLoading, + trailing: viewModel.isDataValid ? .arrowForward : nil, + action: { + resignFirstResponder() + Task { + await viewModel.action() + } + } + ) + .padding(.bottom, 20) + } + .padding(.horizontal, 16) + } + .toolbar { + ToolbarItem(placement: .principal) { + Text(L10n.withdraw) + .fontWeight(.semibold) + } + } + .onDisappear { + resignFirstResponder() + } + } + + var form: some View { + VStack(spacing: 12) { + StrigaFormCell( + title: L10n.iban, + status: viewModel.fieldsStatuses[.IBAN] + ) { + StrigaRegistrationTextField( + field: .IBAN, + placeholder: "", + text: $viewModel.IBAN, + focus: $focus, + onSubmit: { focus = .BIC }, + submitLabel: .next + ) + } + + StrigaFormCell( + title: L10n.bic, + status: viewModel.fieldsStatuses[.BIC] + ) { + StrigaRegistrationTextField( + field: .BIC, + placeholder: "", + text: $viewModel.BIC, + focus: $focus, + onSubmit: { focus = nil }, + submitLabel: .done + ) + } + + VStack(spacing: 4) { + StrigaFormCell( + title: "Receiver", + status: .valid) { + StrigaRegistrationTextField( + field: .receiver, + placeholder: "", + text: $viewModel.receiver, + isEnabled: false, + focus: $focus, + onSubmit: { focus = nil }, + submitLabel: .next + ) + } + Text("Your bank account name must match the name registered to your Key App account") + .apply(style: .label1) + .foregroundColor(Color(Asset.Colors.night.color)) + } + } + } +} + +//struct WithdrawView_Previews: PreviewProvider { +// static var previews: some View { +// WithdrawView(viewModel: WithdrawViewModel(provider: Resolver.resolve(), withdrawalInfo: nil)) +// } +//} + + +enum WithdrawViewField: Int, Identifiable { + var id: Int { rawValue } + + case IBAN + case BIC + case receiver +} + +extension WithdrawView { + // When screen disappear via some action, you should called this method twice for some reason: on action and on disappear function + // Seems like a UI bug of iOS https://stackoverflow.com/a/74124962 + private func resignFirstResponder() { + UIApplication.shared.sendAction( + #selector(UIResponder.resignFirstResponder), + to: nil, + from: nil, + for: nil + ) + } +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/Withdraw/WithdrawViewModel.swift b/p2p_wallet/Scenes/Main/BankTransfer/Withdraw/WithdrawViewModel.swift new file mode 100644 index 0000000000..06fe3b07f8 --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/Withdraw/WithdrawViewModel.swift @@ -0,0 +1,173 @@ +import BankTransfer +import Combine +import Foundation +import Resolver + +class WithdrawViewModel: BaseViewModel, ObservableObject { + typealias FieldStatus = StrigaFormTextFieldStatus + + // MARK: - + + @Injected var bankTransferService: AnyBankTransferService + @Injected var notificationService: NotificationService + + // MARK: - + + @Published var IBAN: String = "" + @Published var BIC: String = "" + @Published var receiver: String = "" + @Published var actionTitle: String = "Withdraw" + @Published var isDataValid = false + @Published var fieldsStatuses = [WithdrawViewField: FieldStatus]() + @Published var isLoading = false + @Published private var actionHasBeenTapped = false + + private let actionCompletedSubject = PassthroughSubject() + public var actionCompletedPublisher: AnyPublisher { + actionCompletedSubject.eraseToAnyPublisher() + } + + init( + withdrawalInfo: StrigaWithdrawalInfo + ) { + super.init() + + self.IBAN = withdrawalInfo.IBAN ?? "" + self.BIC = withdrawalInfo.BIC ?? "" + self.receiver = withdrawalInfo.receiver + + Publishers.CombineLatest3($IBAN, $BIC, $actionHasBeenTapped) + .drop(while: { _, _, actionHasBeenTapped in + !actionHasBeenTapped + }) + .map { iban, bic, _ in + [ + WithdrawViewField.IBAN: self.checkIBAN(iban), + WithdrawViewField.BIC: self.checkBIC(bic) + ] + } + .assignWeak(to: \.fieldsStatuses, on: self) + .store(in: &subscriptions) + + $IBAN + .debounce(for: 0.1, scheduler: DispatchQueue.main) + .removeDuplicates() + .map { self.formatIBAN($0) } + .assignWeak(to: \.IBAN, on: self) + .store(in: &subscriptions) + } + + func action() async { + actionHasBeenTapped = true + guard !isLoading, checkIBAN(IBAN) == .valid, checkBIC(BIC) == .valid else { + return + } + + isLoading = true + defer { + isLoading = false + } + // Save to local + do { + try await bankTransferService.value.saveWithdrawalInfo(info: + .init( + IBAN: IBAN.filterIBAN(), + BIC: BIC, + receiver: receiver + ) + ) + } catch { + notificationService.showDefaultErrorNotification() + } + actionCompletedSubject.send() + } + + func formatIBAN(_ iban: String) -> String { + // Remove any spaces or special characters from the input string + let cleanedIBAN = iban.components(separatedBy: CharacterSet.alphanumerics.inverted).joined() + + // Check if the IBAN is empty or not valid (less than 4 characters) + guard cleanedIBAN.count >= 4 else { + return cleanedIBAN + } + + // Create a formatted IBAN by grouping characters in blocks of four + var formattedIBAN = "" + var index = cleanedIBAN.startIndex + + while index < cleanedIBAN.endIndex { + let nextIndex = cleanedIBAN.index(index, offsetBy: 4, limitedBy: cleanedIBAN.endIndex) ?? cleanedIBAN.endIndex + let block = cleanedIBAN[index.. FieldStatus { + let filteredIBAN = iban.filterIBAN() + if filteredIBAN.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return .invalid(error: WithdrawViewFieldError.empty.rawValue) + } + return filteredIBAN.passesMod97Check() ? .valid : .invalid(error: WithdrawViewFieldError.invalidIBAN.rawValue) + } + + private func checkBIC(_ bic: String) -> FieldStatus { + let bic = bic.trimmingCharacters(in: .whitespacesAndNewlines) + if bic.isEmpty { + return .invalid(error: WithdrawViewFieldError.empty.rawValue) + } + return bic.passesBICCheck() ? .valid : .invalid(error: WithdrawViewFieldError.invalidBIC.rawValue) + } +} + +// Validation +private extension String { + private func mod97() -> Int { + let symbols: [Character] = Array(self) + let swapped = symbols.dropFirst(4) + symbols.prefix(4) + + let mod: Int = swapped.reduce(0) { (previousMod, char) in + let value = Int(String(char), radix: 36)! // "0" => 0, "A" => 10, "Z" => 35 + let factor = value < 10 ? 10 : 100 + return (factor * previousMod + value) % 97 + } + return mod + } + + func passesMod97Check() -> Bool { + guard count >= 4 else { + return false + } + + let uppercase = uppercased() + + guard uppercase.range(of: "^[0-9A-Z]*$", options: .regularExpression) != nil else { + return false + } + return (uppercase.mod97() == 1) + } + + func passesBICCheck() -> Bool { + let bicRegex = "^([A-Za-z]{4}[A-Za-z]{2})([A-Za-z0-9]{2})([A-Za-z0-9]{3})?$" + let bicTest = NSPredicate(format: "SELF MATCHES %@", bicRegex) + return bicTest.evaluate(with: self) + } + + func filterIBAN() -> String { + // Use a character set containing allowed characters (alphanumeric and spaces) + let allowedCharacterSet = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789") + // Remove any characters not in the allowed set + return self.components(separatedBy: allowedCharacterSet.inverted).joined() + } +} + +enum WithdrawViewFieldError: String { + case empty = "Could not be empty" + case invalidIBAN = "Invalid IBAN" + case invalidBIC = "Invalid BIC" +} diff --git a/p2p_wallet/Scenes/Main/NewHome/HomeCoordinator.swift b/p2p_wallet/Scenes/Main/NewHome/HomeCoordinator.swift index 18a9fe5a55..816c2c4af1 100644 --- a/p2p_wallet/Scenes/Main/NewHome/HomeCoordinator.swift +++ b/p2p_wallet/Scenes/Main/NewHome/HomeCoordinator.swift @@ -97,7 +97,6 @@ final class HomeCoordinator: Coordinator { } .sink(receiveValue: {}) .store(in: &subscriptions) - // return publisher return resultSubject.prefix(1).eraseToAnyPublisher() }