From 634d091eb287f68d48885db6ce6c4913bbd89636 Mon Sep 17 00:00:00 2001 From: Boris Nikolic Date: Wed, 23 Oct 2024 17:46:46 +0200 Subject: [PATCH 01/12] Fetch the list of available card networks --- ...eadlessCheckoutRawDataViewController.swift | 1 - ...rmPaymentMethodTokenizationViewModel.swift | 92 +++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/Debug App/Sources/View Controllers/MerchantHeadlessCheckoutRawDataViewController.swift b/Debug App/Sources/View Controllers/MerchantHeadlessCheckoutRawDataViewController.swift index 6f55512a62..3b0b484ae4 100644 --- a/Debug App/Sources/View Controllers/MerchantHeadlessCheckoutRawDataViewController.swift +++ b/Debug App/Sources/View Controllers/MerchantHeadlessCheckoutRawDataViewController.swift @@ -215,7 +215,6 @@ class MerchantHeadlessCheckoutRawDataViewController: UIViewController { extension MerchantHeadlessCheckoutRawDataViewController: UITextFieldDelegate { func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { - print("TextField called") let text = textField.text var newText: String = "" diff --git a/Sources/PrimerSDK/Classes/PCI/Tokenization View Models/FormsTokenizationViewModel/CardFormPaymentMethodTokenizationViewModel.swift b/Sources/PrimerSDK/Classes/PCI/Tokenization View Models/FormsTokenizationViewModel/CardFormPaymentMethodTokenizationViewModel.swift index e14ac7c18c..1ca33e8def 100644 --- a/Sources/PrimerSDK/Classes/PCI/Tokenization View Models/FormsTokenizationViewModel/CardFormPaymentMethodTokenizationViewModel.swift +++ b/Sources/PrimerSDK/Classes/PCI/Tokenization View Models/FormsTokenizationViewModel/CardFormPaymentMethodTokenizationViewModel.swift @@ -34,6 +34,21 @@ class CardFormPaymentMethodTokenizationViewModel: PaymentMethodTokenizationViewM return manager }() + // Used for Co-Badged Cards feature + private let cardPaymentMethodName = "PAYMENT_CARD" + private lazy var rawDataManager: PrimerHeadlessUniversalCheckout.RawDataManager? = { + // If the manager is not resolved (nil) Co-Badged cards feature will just not work and the card form should be working as before + let manager = try? PrimerHeadlessUniversalCheckout.RawDataManager(paymentMethodType: cardPaymentMethodName, delegate: self) + return manager + }() + + private var rawCardData = PrimerCardData(cardNumber: "", + expiryDate: "", + cvv: "", + cardholderName: "") + fileprivate var currentModels: [PrimerCardNetwork]? + + private let theme: PrimerThemeProtocol = DependencyContainer.resolve() var userInputCompletion: (() -> Void)? @@ -860,6 +875,11 @@ extension CardFormPaymentMethodTokenizationViewModel: PrimerTextFieldViewDelegat func primerTextFieldView(_ primerTextFieldView: PrimerTextFieldView, didDetectCardNetwork cardNetwork: CardNetwork?) { self.cardNetwork = cardNetwork + if let text = primerTextFieldView.textField.internalText { + rawCardData.cardNumber = text.replacingOccurrences(of: " ", with: "") + rawDataManager?.rawData = rawCardData + } + var network = self.cardNetwork?.rawValue.uppercased() let clientSessionActionsModule: ClientSessionActionsProtocol = ClientSessionActionsModule() @@ -987,6 +1007,78 @@ extension CardFormPaymentMethodTokenizationViewModel: UITextFieldDelegate { } } +// MARK: - PrimerHeadlessUniversalCheckoutRawDataManagerDelegate +extension CardFormPaymentMethodTokenizationViewModel: PrimerHeadlessUniversalCheckoutRawDataManagerDelegate { + + func primerRawDataManager(_ rawDataManager: PrimerHeadlessUniversalCheckout.RawDataManager, + dataIsValid isValid: Bool, + errors: [Swift.Error]?) { + let errorsDescription = errors?.map { $0.localizedDescription }.joined(separator: ", ") + logger.debug(message: "dataIsValid: \(isValid), errors: \(errorsDescription ?? "none")") + } + + func primerRawDataManager(_ rawDataManager: PrimerHeadlessUniversalCheckout.RawDataManager, + metadataDidChange metadata: [String : Any]?) { + logger.debug(message: "metadataDidChange: \(metadata ?? [:])") + } + + func primerRawDataManager(_ rawDataManager: PrimerHeadlessUniversalCheckout.RawDataManager, + willFetchMetadataForState cardState: PrimerValidationState) { + guard let state = cardState as? PrimerCardNumberEntryState else { + logger.error(message: "Received non-card metadata. Ignoring ...") + return + } + logger.debug(message: "willFetchCardMetadataForState: \(state.cardNumber)") + } + + func primerRawDataManager(_ rawDataManager: PrimerHeadlessUniversalCheckout.RawDataManager, + didReceiveMetadata metadata: PrimerPaymentMethodMetadata, + forState cardState: PrimerValidationState) { + + guard let metadata = metadata as? PrimerCardNumberEntryMetadata, + let cardState = cardState as? PrimerCardNumberEntryState else { + logger.error(message: "Received non-card metadata. Ignoring ...") + return + } + + let metadataDescription = metadata.selectableCardNetworks?.items.map { $0.displayName }.joined(separator: ", ") ?? "n/a" + logger.debug(message: "didReceiveCardMetadata: (selectable ->) \(metadataDescription), cardState: \(cardState.cardNumber)") + + var isAllowed = true + + if metadata.source == .remote, let networks = metadata.selectableCardNetworks?.items, !networks.isEmpty { + currentModels = metadata.selectableCardNetworks?.items + } else if let preferredDetectedNetwork = metadata.detectedCardNetworks.preferred { + currentModels = [preferredDetectedNetwork] + } else if let cardNetwork = metadata.detectedCardNetworks.items.first { + currentModels = [cardNetwork] + isAllowed = false + } else { + currentModels = [] + } + + print(currentModels ?? "no models") + +// let models = currentModels? +// .filter { $0.displayName != "Unknown" } +// .enumerated() +// .map { index, model in +// CardDisplayModel(index: index, +// name: model.displayName, +// image: image(from: model), +// isAllowed: isAllowed, +// value: model.network) +// } +// +// modelsDelegate?.didReceiveCardModels(models: models ?? []) + } + + private func image(from model: PrimerCardNetwork) -> UIImage? { + let asset = PrimerHeadlessUniversalCheckout.AssetsManager.getCardNetworkAsset(for: model.network) + return asset?.cardImage + } +} + private extension String { func lowercasedAndFolded() -> String { self From 2173b96af8b656adc55883569e8b68ccb9e25e18 Mon Sep 17 00:00:00 2001 From: Boris Nikolic Date: Wed, 30 Oct 2024 16:29:37 +0100 Subject: [PATCH 02/12] Port card networks to the view --- .../Models/PrimerCardValidationModels.swift | 4 +++ ...rmPaymentMethodTokenizationViewModel.swift | 32 ++++++++----------- .../Root/PrimerCardFormViewController.swift | 13 +++++--- 3 files changed, 26 insertions(+), 23 deletions(-) diff --git a/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Models/PrimerCardValidationModels.swift b/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Models/PrimerCardValidationModels.swift index 133d5fd602..c4cd83816c 100644 --- a/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Models/PrimerCardValidationModels.swift +++ b/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Models/PrimerCardValidationModels.swift @@ -54,6 +54,10 @@ public class PrimerCardNetwork: NSObject { guard let network = network else { return nil } self.init(network: network) } + + override public var description: String { + return "PrimerCardNetwork(displayName: \(displayName), network: \(network), allowed: \(allowed))" + } } @objc diff --git a/Sources/PrimerSDK/Classes/PCI/Tokenization View Models/FormsTokenizationViewModel/CardFormPaymentMethodTokenizationViewModel.swift b/Sources/PrimerSDK/Classes/PCI/Tokenization View Models/FormsTokenizationViewModel/CardFormPaymentMethodTokenizationViewModel.swift index df76b36aca..1e68c397fd 100644 --- a/Sources/PrimerSDK/Classes/PCI/Tokenization View Models/FormsTokenizationViewModel/CardFormPaymentMethodTokenizationViewModel.swift +++ b/Sources/PrimerSDK/Classes/PCI/Tokenization View Models/FormsTokenizationViewModel/CardFormPaymentMethodTokenizationViewModel.swift @@ -46,7 +46,13 @@ class CardFormPaymentMethodTokenizationViewModel: PaymentMethodTokenizationViewM expiryDate: "", cvv: "", cardholderName: "") - fileprivate var currentModels: [PrimerCardNetwork]? + fileprivate var currentCardNetworks: [PrimerCardNetwork]? { + didSet { + // Trigger the closure to inform the view of the update + onCurrentCardNetworksUpdated?(currentCardNetworks) + } + } + var onCurrentCardNetworksUpdated: (([PrimerCardNetwork]?) -> Void)? private let theme: PrimerThemeProtocol = DependencyContainer.resolve() @@ -1099,30 +1105,18 @@ extension CardFormPaymentMethodTokenizationViewModel: PrimerHeadlessUniversalChe var isAllowed = true if metadata.source == .remote, let networks = metadata.selectableCardNetworks?.items, !networks.isEmpty { - currentModels = metadata.selectableCardNetworks?.items + currentCardNetworks = metadata.selectableCardNetworks?.items } else if let preferredDetectedNetwork = metadata.detectedCardNetworks.preferred { - currentModels = [preferredDetectedNetwork] + currentCardNetworks = [preferredDetectedNetwork] } else if let cardNetwork = metadata.detectedCardNetworks.items.first { - currentModels = [cardNetwork] + currentCardNetworks = [cardNetwork] isAllowed = false } else { - currentModels = [] + currentCardNetworks = [] } - print(currentModels ?? "no models") - -// let models = currentModels? -// .filter { $0.displayName != "Unknown" } -// .enumerated() -// .map { index, model in -// CardDisplayModel(index: index, -// name: model.displayName, -// image: image(from: model), -// isAllowed: isAllowed, -// value: model.network) -// } -// -// modelsDelegate?.didReceiveCardModels(models: models ?? []) + currentCardNetworks = currentCardNetworks? + .filter { $0.displayName != "Unknown" } } private func image(from model: PrimerCardNetwork) -> UIImage? { diff --git a/Sources/PrimerSDK/Classes/PCI/User Interface/Root/PrimerCardFormViewController.swift b/Sources/PrimerSDK/Classes/PCI/User Interface/Root/PrimerCardFormViewController.swift index 77b995cb59..69a6980a96 100644 --- a/Sources/PrimerSDK/Classes/PCI/User Interface/Root/PrimerCardFormViewController.swift +++ b/Sources/PrimerSDK/Classes/PCI/User Interface/Root/PrimerCardFormViewController.swift @@ -13,11 +13,12 @@ class PrimerCardFormViewController: PrimerFormViewController { private let theme: PrimerThemeProtocol = DependencyContainer.resolve() private let formPaymentMethodTokenizationViewModel: CardFormPaymentMethodTokenizationViewModel - init(navigationBarLogo: UIImage? = nil, viewModel: CardFormPaymentMethodTokenizationViewModel) { - self.formPaymentMethodTokenizationViewModel = viewModel + init(navigationBarLogo: UIImage? = nil, + viewModel: CardFormPaymentMethodTokenizationViewModel) { + formPaymentMethodTokenizationViewModel = viewModel super.init(nibName: nil, bundle: nil) - self.titleImage = navigationBarLogo - if self.titleImage == nil { + titleImage = navigationBarLogo + if titleImage == nil { title = Strings.PrimerCardFormView.title } } @@ -43,6 +44,10 @@ class PrimerCardFormViewController: PrimerFormViewController { ) Analytics.Service.record(event: viewEvent) + formPaymentMethodTokenizationViewModel.onCurrentCardNetworksUpdated = { networks in + print("Current card networks: \(String(describing: networks))") + } + setupView() } From e2e290d7309a176969224e889bb4b633ef9eaf00 Mon Sep 17 00:00:00 2001 From: Boris Nikolic Date: Thu, 31 Oct 2024 10:54:07 +0100 Subject: [PATCH 03/12] Implement UI --- ...rmPaymentMethodTokenizationViewModel.swift | 26 +- .../Text Fields/PrimerCustomTextField.swift | 239 ++++++++++++++---- 2 files changed, 197 insertions(+), 68 deletions(-) diff --git a/Sources/PrimerSDK/Classes/PCI/Tokenization View Models/FormsTokenizationViewModel/CardFormPaymentMethodTokenizationViewModel.swift b/Sources/PrimerSDK/Classes/PCI/Tokenization View Models/FormsTokenizationViewModel/CardFormPaymentMethodTokenizationViewModel.swift index 1e68c397fd..5016cdd4d2 100644 --- a/Sources/PrimerSDK/Classes/PCI/Tokenization View Models/FormsTokenizationViewModel/CardFormPaymentMethodTokenizationViewModel.swift +++ b/Sources/PrimerSDK/Classes/PCI/Tokenization View Models/FormsTokenizationViewModel/CardFormPaymentMethodTokenizationViewModel.swift @@ -46,15 +46,9 @@ class CardFormPaymentMethodTokenizationViewModel: PaymentMethodTokenizationViewM expiryDate: "", cvv: "", cardholderName: "") - fileprivate var currentCardNetworks: [PrimerCardNetwork]? { - didSet { - // Trigger the closure to inform the view of the update - onCurrentCardNetworksUpdated?(currentCardNetworks) - } - } + fileprivate var currentCardNetworks: [PrimerCardNetwork]? var onCurrentCardNetworksUpdated: (([PrimerCardNetwork]?) -> Void)? - private let theme: PrimerThemeProtocol = DependencyContainer.resolve() var userInputCompletion: (() -> Void)? @@ -943,12 +937,12 @@ extension CardFormPaymentMethodTokenizationViewModel: PrimerTextFieldViewDelegat if let cardNetwork = cardNetwork, cardNetwork != .unknown, - cardNumberContainerView.rightImage2 != cardNetwork.icon { + cardNumberContainerView.rightImage != cardNetwork.icon { if network == nil || network == "UNKNOWN" { network = "OTHER" } - cardNumberContainerView.rightImage2 = cardNetwork.icon + cardNumberContainerView.rightImage = cardNetwork.icon firstly { clientSessionActionsModule.selectPaymentMethodIfNeeded(self.config.type, cardNetwork: network) @@ -957,8 +951,8 @@ extension CardFormPaymentMethodTokenizationViewModel: PrimerTextFieldViewDelegat self.updateButtonUI() } .catch { _ in } - } else if cardNumberContainerView.rightImage2 != nil && (cardNetwork?.icon == nil || cardNetwork == .unknown) { - cardNumberContainerView.rightImage2 = nil + } else if cardNumberContainerView.rightImage != nil && (cardNetwork?.icon == nil || cardNetwork == .unknown) { + cardNumberContainerView.rightImage = nil firstly { clientSessionActionsModule.unselectPaymentMethodIfNeeded() @@ -1102,21 +1096,21 @@ extension CardFormPaymentMethodTokenizationViewModel: PrimerHeadlessUniversalChe let metadataDescription = metadata.selectableCardNetworks?.items.map { $0.displayName }.joined(separator: ", ") ?? "n/a" logger.debug(message: "didReceiveCardMetadata: (selectable ->) \(metadataDescription), cardState: \(cardState.cardNumber)") - var isAllowed = true - if metadata.source == .remote, let networks = metadata.selectableCardNetworks?.items, !networks.isEmpty { currentCardNetworks = metadata.selectableCardNetworks?.items } else if let preferredDetectedNetwork = metadata.detectedCardNetworks.preferred { currentCardNetworks = [preferredDetectedNetwork] } else if let cardNetwork = metadata.detectedCardNetworks.items.first { currentCardNetworks = [cardNetwork] - isAllowed = false } else { currentCardNetworks = [] } - currentCardNetworks = currentCardNetworks? - .filter { $0.displayName != "Unknown" } + currentCardNetworks = currentCardNetworks?.filter { $0.displayName != "Unknown" } + + // Trigger the closure to inform the view of the update + onCurrentCardNetworksUpdated?(currentCardNetworks) + cardNumberContainerView.cardNetworks = currentCardNetworks ?? [] } private func image(from model: PrimerCardNetwork) -> UIImage? { diff --git a/Sources/PrimerSDK/Classes/PCI/User Interface/Text Fields/PrimerCustomTextField.swift b/Sources/PrimerSDK/Classes/PCI/User Interface/Text Fields/PrimerCustomTextField.swift index 3e2e6ab713..bc390ae5fe 100644 --- a/Sources/PrimerSDK/Classes/PCI/User Interface/Text Fields/PrimerCustomTextField.swift +++ b/Sources/PrimerSDK/Classes/PCI/User Interface/Text Fields/PrimerCustomTextField.swift @@ -9,34 +9,40 @@ import UIKit class PrimerCustomFieldView: UIView { + // MARK: - Properties + var fieldView: PrimerTextFieldView! + var cardNetworks: [PrimerCardNetwork] = [] { + didSet { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.updateNetworksDropdownViewVisibility() + } + } + } + private var textFieldStackView: UIStackView! + var networksDropdownView: UIView? + private var presentationButton: UIButton! // Button for presenting menu programmatically + var onCardNetworkSelected: ((PrimerCardNetwork) -> Void)? + var selectedCardNetwork: PrimerCardNetwork? + override var tintColor: UIColor! { didSet { topPlaceholderLabel.textColor = tintColor bottomLine.backgroundColor = tintColor } } + var placeholderText: String? - var rightImage1: UIImage? { - didSet { - rightImageView1Container.isHidden = rightImage1 == nil - rightImageView1.image = rightImage1 - } - } - var rightImage1TintColor: UIColor? { - didSet { - rightImageView1.tintColor = rightImage1TintColor - } - } - var rightImage2: UIImage? { + var rightImage: UIImage? { didSet { - rightImageView2.isHidden = rightImage2 == nil - rightImageView2.image = rightImage2 + rightImageView.isHidden = rightImage == nil + rightImageView.image = rightImage } } - var rightImage2TintColor: UIColor? { + var rightImageTintColor: UIColor? { didSet { - rightImageView2.tintColor = rightImage2TintColor + rightImageView.tintColor = rightImageTintColor } } var errorText: String? { @@ -48,13 +54,17 @@ class PrimerCustomFieldView: UIView { private var verticalStackView: UIStackView = UIStackView() private let errorLabel = UILabel() private let topPlaceholderLabel = UILabel() - private let rightImageView1Container = UIView() - private let rightImageView1 = UIImageView() // As in the one furthest right - private let rightImageView2Container = UIView() - private let rightImageView2 = UIImageView() + private let rightImageViewContainer = UIView() + private let rightImageView = UIImageView() private let bottomLine = UIView() private var theme: PrimerThemeProtocol = DependencyContainer.resolve() + // References to image views inside the dropdown + private var networkIconImageView: UIImageView? + private var chevronImageView: UIImageView? + + // MARK: - Setup Methods + func setup() { setupVerticalStackView() setupTopPlaceholderLabel() @@ -78,52 +88,140 @@ class PrimerCustomFieldView: UIView { } private func setupTextFieldStackView() { - let textFieldStackView = createTextFieldStackView() + textFieldStackView = UIStackView() + textFieldStackView.alignment = .fill + textFieldStackView.axis = .horizontal + textFieldStackView.spacing = 6 - setupRightImageView2Container(in: textFieldStackView) - setupRightImageView1Container(in: textFieldStackView) + // Add the fieldView + textFieldStackView.addArrangedSubview(fieldView) + fieldView.setContentHuggingPriority(.defaultLow, for: .horizontal) + fieldView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + setupRightImageViewContainer(in: textFieldStackView) verticalStackView.addArrangedSubview(textFieldStackView) } - private func createTextFieldStackView() -> UIStackView { - let textFieldStackView = UIStackView() - textFieldStackView.alignment = .fill - textFieldStackView.axis = .horizontal - textFieldStackView.addArrangedSubview(fieldView) - textFieldStackView.spacing = 6 - return textFieldStackView + private func setupNetworksDropdownView() { + guard networksDropdownView == nil else { return } + + let dropdownView = createDropdownView() + let iconAndChevronStack = createIconAndChevronStack() + + dropdownView.addSubview(iconAndChevronStack) + activateConstraints(for: iconAndChevronStack, in: dropdownView) + + setupPresentationButton(in: dropdownView) + + networksDropdownView = dropdownView + textFieldStackView.addArrangedSubview(dropdownView) + setDropdownViewConstraints(dropdownView) + } + + // MARK: - Helper Methods + + private func createDropdownView() -> UIView { + let dropdownView = UIView() + dropdownView.isUserInteractionEnabled = true + return dropdownView + } + + private func createIconAndChevronStack() -> UIStackView { + let iconAndChevronStack = UIStackView() + iconAndChevronStack.axis = .horizontal + iconAndChevronStack.alignment = .center + iconAndChevronStack.spacing = 4 + + let networkIconImageView = UIImageView(image: cardNetworks.first?.network.icon) + self.networkIconImageView = networkIconImageView + networkIconImageView.contentMode = .scaleAspectFit + iconAndChevronStack.addArrangedSubview(networkIconImageView) + + let chevronImageView = UIImageView(image: UIImage(systemName: "chevron.down", withConfiguration: UIImage.SymbolConfiguration(scale: .small))) + self.chevronImageView = chevronImageView + chevronImageView.contentMode = .scaleAspectFit + iconAndChevronStack.addArrangedSubview(chevronImageView) + + return iconAndChevronStack + } + + private func activateConstraints(for stackView: UIStackView, in containerView: UIView) { + stackView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(equalTo: containerView.topAnchor), + stackView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + stackView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor) + ]) + } + + private func setupPresentationButton(in dropdownView: UIView) { + presentationButton = UIButton(type: .system) + presentationButton.alpha = 0.1 + presentationButton.translatesAutoresizingMaskIntoConstraints = false + + if #available(iOS 15.0, *) { + setupUIMenuForButton() + } else { + presentationButton.addTarget(self, action: #selector(showCardNetworkSelectionAlert), for: .touchUpInside) + } + + dropdownView.addSubview(presentationButton) } - private func setupRightImageView2Container(in stackView: UIStackView) { - rightImageView2.contentMode = .scaleAspectFit + @available(iOS 15.0, *) + private func setupUIMenuForButton() { + let uiActions = cardNetworks.map { network in + UIAction(title: network.displayName, image: network.network.icon) { _ in + self.selectedCardNetwork = network + self.networkIconImageView?.image = network.network.icon + self.onCardNetworkSelected?(network) + } + } + + let menu = UIMenu(options: .singleSelection, children: uiActions) + presentationButton.menu = menu + presentationButton.showsMenuAsPrimaryAction = true + } - stackView.addArrangedSubview(rightImageView2Container) - rightImageView2Container.translatesAutoresizingMaskIntoConstraints = false - rightImageView2Container.addSubview(rightImageView2) - rightImageView2.translatesAutoresizingMaskIntoConstraints = false + private func setDropdownViewConstraints(_ dropdownView: UIView) { + dropdownView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ - rightImageView2.topAnchor.constraint(equalTo: rightImageView2Container.topAnchor, constant: 6), - rightImageView2.bottomAnchor.constraint(equalTo: rightImageView2Container.bottomAnchor, constant: -6), - rightImageView2.leadingAnchor.constraint(equalTo: rightImageView2Container.leadingAnchor), - rightImageView2.trailingAnchor.constraint(equalTo: rightImageView2Container.trailingAnchor), - rightImageView2.widthAnchor.constraint(equalTo: rightImageView2Container.heightAnchor) + dropdownView.widthAnchor.constraint(equalToConstant: 44), + dropdownView.heightAnchor.constraint(equalToConstant: 36), + presentationButton.widthAnchor.constraint(equalToConstant: 44), + presentationButton.heightAnchor.constraint(equalToConstant: 36) ]) + dropdownView.setContentHuggingPriority(.defaultHigh, for: .horizontal) + dropdownView.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) } - private func setupRightImageView1Container(in stackView: UIStackView) { - rightImageView1.contentMode = .scaleAspectFit + private func updateNetworksDropdownViewVisibility() { + if cardNetworks.count > 1 { + if networksDropdownView == nil { + setupNetworksDropdownView() + } + networksDropdownView?.isHidden = false + rightImageViewContainer.isHidden = true + } else { + networksDropdownView?.isHidden = true + rightImageViewContainer.isHidden = false + } + } + + private func setupRightImageViewContainer(in stackView: UIStackView) { + rightImageView.contentMode = .scaleAspectFit - stackView.addArrangedSubview(rightImageView1Container) - rightImageView1Container.translatesAutoresizingMaskIntoConstraints = false - rightImageView1Container.addSubview(rightImageView1) - rightImageView1.translatesAutoresizingMaskIntoConstraints = false + stackView.addArrangedSubview(rightImageViewContainer) + rightImageViewContainer.translatesAutoresizingMaskIntoConstraints = false + rightImageViewContainer.addSubview(rightImageView) + rightImageView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ - rightImageView1.topAnchor.constraint(equalTo: rightImageView1Container.topAnchor, constant: 10), - rightImageView1.bottomAnchor.constraint(equalTo: rightImageView1Container.bottomAnchor, constant: -10), - rightImageView1.leadingAnchor.constraint(equalTo: rightImageView1Container.leadingAnchor), - rightImageView1.trailingAnchor.constraint(equalTo: rightImageView1Container.trailingAnchor), - rightImageView1.widthAnchor.constraint(equalTo: rightImageView1Container.heightAnchor) + rightImageView.topAnchor.constraint(equalTo: rightImageViewContainer.topAnchor, constant: 6), + rightImageView.bottomAnchor.constraint(equalTo: rightImageViewContainer.bottomAnchor, constant: -6), + rightImageView.leadingAnchor.constraint(equalTo: rightImageViewContainer.leadingAnchor), + rightImageView.trailingAnchor.constraint(equalTo: rightImageViewContainer.trailingAnchor), + rightImageView.widthAnchor.constraint(equalTo: rightImageViewContainer.heightAnchor) ]) } @@ -150,4 +248,41 @@ class PrimerCustomFieldView: UIView { verticalStackView.bottomAnchor.constraint(equalTo: bottomAnchor) ]) } + + // MARK: - Dropdown Actions + + @objc private func showCardNetworkSelectionAlert() { + // Use UIAlertController for earlier iOS versions + let alertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) + + // Create UIAlertAction for each network and add to the alert + cardNetworks.forEach { network in + let action = UIAlertAction(title: network.displayName, style: .default) { _ in + self.selectedCardNetwork = network + self.networkIconImageView?.image = network.network.icon + self.onCardNetworkSelected?(network) + } + action.setValue(network.network.icon?.withRenderingMode(.alwaysOriginal), forKey: "image") + alertController.addAction(action) + } + + // Present the alert controller + if let viewController = UIApplication.shared.keyWindow?.rootViewController { + viewController.present(alertController, animated: true) + } + } +} + +fileprivate extension UIApplication { + var windows: [UIWindow] { + let windowScene = self.connectedScenes.compactMap { $0 as? UIWindowScene }.first + guard let windows = windowScene?.windows else { + return [] + } + return windows + } + + var keyWindow: UIWindow? { + return windows.first(where: { $0.isKeyWindow }) + } } From b623101d011a68f83b651a2fe6a1ee6961172e2f Mon Sep 17 00:00:00 2001 From: Boris Nikolic Date: Thu, 31 Oct 2024 12:12:32 +0100 Subject: [PATCH 04/12] Add comments --- .../Text Fields/PrimerCustomTextField.swift | 65 ++++++++++++++----- 1 file changed, 48 insertions(+), 17 deletions(-) diff --git a/Sources/PrimerSDK/Classes/PCI/User Interface/Text Fields/PrimerCustomTextField.swift b/Sources/PrimerSDK/Classes/PCI/User Interface/Text Fields/PrimerCustomTextField.swift index bc390ae5fe..ca6bff2108 100644 --- a/Sources/PrimerSDK/Classes/PCI/User Interface/Text Fields/PrimerCustomTextField.swift +++ b/Sources/PrimerSDK/Classes/PCI/User Interface/Text Fields/PrimerCustomTextField.swift @@ -11,7 +11,10 @@ class PrimerCustomFieldView: UIView { // MARK: - Properties + // Custom text field view for input var fieldView: PrimerTextFieldView! + + // Array of supported card networks, updating the dropdown view visibility on change var cardNetworks: [PrimerCardNetwork] = [] { didSet { DispatchQueue.main.async { [weak self] in @@ -20,51 +23,62 @@ class PrimerCustomFieldView: UIView { } } } - private var textFieldStackView: UIStackView! - var networksDropdownView: UIView? - private var presentationButton: UIButton! // Button for presenting menu programmatically + + private var textFieldStackView: UIStackView! // Stack view containing the text field and dropdown + var networksDropdownView: UIView? // Dropdown view for displaying available card networks + private var presentationButton: UIButton! // Button used to trigger menu programmatically + + // Callback triggered when a card network is selected var onCardNetworkSelected: ((PrimerCardNetwork) -> Void)? var selectedCardNetwork: PrimerCardNetwork? - override var tintColor: UIColor! { + override var tintColor: UIColor! { // Updates label and line color on tint change didSet { topPlaceholderLabel.textColor = tintColor bottomLine.backgroundColor = tintColor } } + // Placeholder text for the top label var placeholderText: String? + + // Image on the right side of the text field var rightImage: UIImage? { didSet { rightImageView.isHidden = rightImage == nil rightImageView.image = rightImage } } + + // Tint color for the right image var rightImageTintColor: UIColor? { didSet { rightImageView.tintColor = rightImageTintColor } } + + // Error text displayed below the text field var errorText: String? { didSet { errorLabel.text = errorText ?? "" } } - private var verticalStackView: UIStackView = UIStackView() - private let errorLabel = UILabel() - private let topPlaceholderLabel = UILabel() - private let rightImageViewContainer = UIView() - private let rightImageView = UIImageView() - private let bottomLine = UIView() - private var theme: PrimerThemeProtocol = DependencyContainer.resolve() + private var verticalStackView: UIStackView = UIStackView() // Main vertical stack view for layout + private let errorLabel = UILabel() // Label to display error messages + private let topPlaceholderLabel = UILabel() // Label for the placeholder text + private let rightImageViewContainer = UIView() // Container for right-side image view + private let rightImageView = UIImageView() // Right image view + private let bottomLine = UIView() // Line below the text field + private var theme: PrimerThemeProtocol = DependencyContainer.resolve() // Theme for styling - // References to image views inside the dropdown + // References for icon and chevron image views in the dropdown private var networkIconImageView: UIImageView? private var chevronImageView: UIImageView? // MARK: - Setup Methods + // Sets up the view hierarchy and layout func setup() { setupVerticalStackView() setupTopPlaceholderLabel() @@ -74,12 +88,14 @@ class PrimerCustomFieldView: UIView { constrainVerticalStackView() } + // Sets up the main vertical stack view private func setupVerticalStackView() { addSubview(verticalStackView) verticalStackView.alignment = .fill verticalStackView.axis = .vertical } + // Configures the top label for the placeholder text private func setupTopPlaceholderLabel() { topPlaceholderLabel.font = UIFont.systemFont(ofSize: 10.0, weight: .medium) topPlaceholderLabel.text = placeholderText @@ -87,30 +103,35 @@ class PrimerCustomFieldView: UIView { verticalStackView.addArrangedSubview(topPlaceholderLabel) } + // Configures the text field stack view with the field view and right image view private func setupTextFieldStackView() { textFieldStackView = UIStackView() textFieldStackView.alignment = .fill textFieldStackView.axis = .horizontal textFieldStackView.spacing = 6 - // Add the fieldView + // Add the text field to the stack view textFieldStackView.addArrangedSubview(fieldView) fieldView.setContentHuggingPriority(.defaultLow, for: .horizontal) fieldView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + // Adds the right image view container to the stack view setupRightImageViewContainer(in: textFieldStackView) verticalStackView.addArrangedSubview(textFieldStackView) } + // Configures the dropdown view to display available card networks private func setupNetworksDropdownView() { guard networksDropdownView == nil else { return } let dropdownView = createDropdownView() let iconAndChevronStack = createIconAndChevronStack() + // Add stack containing the icon and chevron to the dropdown view dropdownView.addSubview(iconAndChevronStack) activateConstraints(for: iconAndChevronStack, in: dropdownView) + // Sets up the hidden button used to present the dropdown options setupPresentationButton(in: dropdownView) networksDropdownView = dropdownView @@ -120,23 +141,27 @@ class PrimerCustomFieldView: UIView { // MARK: - Helper Methods + // Creates the dropdown view container private func createDropdownView() -> UIView { let dropdownView = UIView() dropdownView.isUserInteractionEnabled = true return dropdownView } + // Creates a horizontal stack view with the network icon and chevron for the dropdown private func createIconAndChevronStack() -> UIStackView { let iconAndChevronStack = UIStackView() iconAndChevronStack.axis = .horizontal iconAndChevronStack.alignment = .center iconAndChevronStack.spacing = 4 + // Creates and adds the network icon let networkIconImageView = UIImageView(image: cardNetworks.first?.network.icon) self.networkIconImageView = networkIconImageView networkIconImageView.contentMode = .scaleAspectFit iconAndChevronStack.addArrangedSubview(networkIconImageView) + // Creates and adds the chevron icon let chevronImageView = UIImageView(image: UIImage(systemName: "chevron.down", withConfiguration: UIImage.SymbolConfiguration(scale: .small))) self.chevronImageView = chevronImageView chevronImageView.contentMode = .scaleAspectFit @@ -145,6 +170,7 @@ class PrimerCustomFieldView: UIView { return iconAndChevronStack } + // Activates constraints to pin the stack view within its container private func activateConstraints(for stackView: UIStackView, in containerView: UIView) { stackView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ @@ -155,6 +181,7 @@ class PrimerCustomFieldView: UIView { ]) } + // Configures the presentation button for triggering the dropdown options private func setupPresentationButton(in dropdownView: UIView) { presentationButton = UIButton(type: .system) presentationButton.alpha = 0.1 @@ -169,6 +196,7 @@ class PrimerCustomFieldView: UIView { dropdownView.addSubview(presentationButton) } + // Configures the UIMenu for iOS 15 and above @available(iOS 15.0, *) private func setupUIMenuForButton() { let uiActions = cardNetworks.map { network in @@ -184,6 +212,7 @@ class PrimerCustomFieldView: UIView { presentationButton.showsMenuAsPrimaryAction = true } + // Sets constraints for the dropdown view and presentation button private func setDropdownViewConstraints(_ dropdownView: UIView) { dropdownView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ @@ -196,6 +225,7 @@ class PrimerCustomFieldView: UIView { dropdownView.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) } + // Updates dropdown visibility based on the number of card networks private func updateNetworksDropdownViewVisibility() { if cardNetworks.count > 1 { if networksDropdownView == nil { @@ -209,9 +239,9 @@ class PrimerCustomFieldView: UIView { } } + // Configures the right image view container in the text field stack view private func setupRightImageViewContainer(in stackView: UIStackView) { rightImageView.contentMode = .scaleAspectFit - stackView.addArrangedSubview(rightImageViewContainer) rightImageViewContainer.translatesAutoresizingMaskIntoConstraints = false rightImageViewContainer.addSubview(rightImageView) @@ -225,12 +255,14 @@ class PrimerCustomFieldView: UIView { ]) } + // Configures the bottom line beneath the text field private func setupBottomLine() { bottomLine.backgroundColor = theme.colors.primary bottomLine.heightAnchor.constraint(equalToConstant: 1).isActive = true verticalStackView.addArrangedSubview(bottomLine) } + // Configures the error label below the text field private func setupErrorLabel() { errorLabel.textColor = theme.text.error.color errorLabel.heightAnchor.constraint(equalToConstant: 12.0).isActive = true @@ -239,6 +271,7 @@ class PrimerCustomFieldView: UIView { verticalStackView.addArrangedSubview(errorLabel) } + // Sets up constraints for the main vertical stack view private func constrainVerticalStackView() { verticalStackView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ @@ -251,11 +284,10 @@ class PrimerCustomFieldView: UIView { // MARK: - Dropdown Actions + // Shows an alert for selecting a card network (for iOS < 15) @objc private func showCardNetworkSelectionAlert() { - // Use UIAlertController for earlier iOS versions let alertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) - // Create UIAlertAction for each network and add to the alert cardNetworks.forEach { network in let action = UIAlertAction(title: network.displayName, style: .default) { _ in self.selectedCardNetwork = network @@ -266,7 +298,6 @@ class PrimerCustomFieldView: UIView { alertController.addAction(action) } - // Present the alert controller if let viewController = UIApplication.shared.keyWindow?.rootViewController { viewController.present(alertController, animated: true) } From 2b80160523feb2af4dc7dc9219b1dd9699d8dec2 Mon Sep 17 00:00:00 2001 From: Boris Nikolic Date: Mon, 4 Nov 2024 19:02:04 +0100 Subject: [PATCH 05/12] Work on handling of the network selection --- ...rmPaymentMethodTokenizationViewModel.swift | 85 ++++++++++++++----- .../InternalCardComponentsManager.swift | 7 +- .../Root/PrimerCardFormViewController.swift | 4 - .../Text Fields/PrimerCustomTextField.swift | 52 +++++++++--- 4 files changed, 111 insertions(+), 37 deletions(-) diff --git a/Sources/PrimerSDK/Classes/PCI/Tokenization View Models/FormsTokenizationViewModel/CardFormPaymentMethodTokenizationViewModel.swift b/Sources/PrimerSDK/Classes/PCI/Tokenization View Models/FormsTokenizationViewModel/CardFormPaymentMethodTokenizationViewModel.swift index 5016cdd4d2..3401a50410 100644 --- a/Sources/PrimerSDK/Classes/PCI/Tokenization View Models/FormsTokenizationViewModel/CardFormPaymentMethodTokenizationViewModel.swift +++ b/Sources/PrimerSDK/Classes/PCI/Tokenization View Models/FormsTokenizationViewModel/CardFormPaymentMethodTokenizationViewModel.swift @@ -46,8 +46,7 @@ class CardFormPaymentMethodTokenizationViewModel: PaymentMethodTokenizationViewM expiryDate: "", cvv: "", cardholderName: "") - fileprivate var currentCardNetworks: [PrimerCardNetwork]? - var onCurrentCardNetworksUpdated: (([PrimerCardNetwork]?) -> Void)? + fileprivate var currentlyAvailableCardNetworks: [PrimerCardNetwork]? private let theme: PrimerThemeProtocol = DependencyContainer.resolve() @@ -96,9 +95,17 @@ class CardFormPaymentMethodTokenizationViewModel: PaymentMethodTokenizationViewM return textField }() - var cardNetwork: CardNetwork? { + var defaultCardNetwork: CardNetwork? { didSet { - cvvField.cardNetwork = cardNetwork ?? .unknown + cvvField.cardNetwork = defaultCardNetwork ?? .unknown + } + } + + var alternativelySelectedCardNetwork: CardNetwork? { + didSet { + if let alternativelySelectedCardNetwork { + cvvField.cardNetwork = alternativelySelectedCardNetwork + } } } @@ -121,7 +128,24 @@ class CardFormPaymentMethodTokenizationViewModel: PaymentMethodTokenizationViewM }() private lazy var cardNumberContainerView: PrimerCustomFieldView = { - PrimerCardNumberField.cardNumberContainerViewWithFieldView(cardNumberField) + let containerView = PrimerCardNumberField.cardNumberContainerViewWithFieldView(cardNumberField) + containerView.onCardNetworkSelected = { [weak self] cardNetwork in + guard let self = self else { return } + self.alternativelySelectedCardNetwork = cardNetwork.network + self.rawCardData.cardNetwork = cardNetwork.network + self.rawDataManager?.rawData = self.rawCardData // TODO: (BNI) This does not work for unknown reason + + // Select payment method based on the detected card network + let clientSessionActionsModule: ClientSessionActionsProtocol = ClientSessionActionsModule() + firstly { + clientSessionActionsModule.selectPaymentMethodIfNeeded(self.config.type, cardNetwork: cardNetwork.network.rawValue) + } + .done { + self.updateButtonUI() + } + .catch { _ in } + } + return containerView }() // MARK: - Cardholder name field @@ -675,7 +699,7 @@ class CardFormPaymentMethodTokenizationViewModel: PaymentMethodTokenizationViewM func configurePayButton(cardNetwork: CardNetwork?) { var amount: Int = AppState.current.amount ?? 0 - if let surcharge = cardNetwork?.surcharge { + if let surcharge = alternativelySelectedCardNetwork?.surcharge ?? cardNetwork?.surcharge { amount += surcharge } @@ -725,7 +749,7 @@ extension CardFormPaymentMethodTokenizationViewModel { private func dispatchActions() -> Promise { return Promise { seal in - var network = self.cardNetwork?.rawValue.uppercased() + var network = self.alternativelySelectedCardNetwork?.rawValue.uppercased() ?? self.defaultCardNetwork?.rawValue.uppercased() if network == nil || network == "UNKNOWN" { network = "OTHER" } @@ -925,25 +949,37 @@ extension CardFormPaymentMethodTokenizationViewModel: PrimerTextFieldViewDelegat } func primerTextFieldView(_ primerTextFieldView: PrimerTextFieldView, didDetectCardNetwork cardNetwork: CardNetwork?) { - self.cardNetwork = cardNetwork + self.defaultCardNetwork = cardNetwork if let text = primerTextFieldView.textField.internalText { rawCardData.cardNumber = text.replacingOccurrences(of: " ", with: "") rawDataManager?.rawData = rawCardData } - var network = self.cardNetwork?.rawValue.uppercased() + DispatchQueue.main.async { + self.handleCardNetworkDetection(cardNetwork) + } + } + + private func handleCardNetworkDetection(_ cardNetwork: CardNetwork?) { + guard alternativelySelectedCardNetwork == nil else { return } + + self.rawCardData.cardNetwork = cardNetwork + self.rawDataManager?.rawData = self.rawCardData + + var network = cardNetwork?.rawValue.uppercased() let clientSessionActionsModule: ClientSessionActionsProtocol = ClientSessionActionsModule() - if let cardNetwork = cardNetwork, - cardNetwork != .unknown, - cardNumberContainerView.rightImage != cardNetwork.icon { + if let cardNetwork = cardNetwork, cardNetwork != .unknown, cardNumberContainerView.rightImageView.image != cardNetwork.icon { + // Set the network value to "OTHER" if it's nil or unknown if network == nil || network == "UNKNOWN" { network = "OTHER" } + // Update the UI with the detected card network icon cardNumberContainerView.rightImage = cardNetwork.icon + // Select payment method based on the detected card network firstly { clientSessionActionsModule.selectPaymentMethodIfNeeded(self.config.type, cardNetwork: network) } @@ -951,7 +987,9 @@ extension CardFormPaymentMethodTokenizationViewModel: PrimerTextFieldViewDelegat self.updateButtonUI() } .catch { _ in } + } else if cardNumberContainerView.rightImage != nil && (cardNetwork?.icon == nil || cardNetwork == .unknown) { + // Unselect payment method and remove the card network icon if unknown or nil cardNumberContainerView.rightImage = nil firstly { @@ -1070,7 +1108,7 @@ extension CardFormPaymentMethodTokenizationViewModel: PrimerHeadlessUniversalChe } func primerRawDataManager(_ rawDataManager: PrimerHeadlessUniversalCheckout.RawDataManager, - metadataDidChange metadata: [String : Any]?) { + metadataDidChange metadata: [String: Any]?) { logger.debug(message: "metadataDidChange: \(metadata ?? [:])") } @@ -1097,20 +1135,25 @@ extension CardFormPaymentMethodTokenizationViewModel: PrimerHeadlessUniversalChe logger.debug(message: "didReceiveCardMetadata: (selectable ->) \(metadataDescription), cardState: \(cardState.cardNumber)") if metadata.source == .remote, let networks = metadata.selectableCardNetworks?.items, !networks.isEmpty { - currentCardNetworks = metadata.selectableCardNetworks?.items + currentlyAvailableCardNetworks = metadata.selectableCardNetworks?.items } else if let preferredDetectedNetwork = metadata.detectedCardNetworks.preferred { - currentCardNetworks = [preferredDetectedNetwork] + currentlyAvailableCardNetworks = [preferredDetectedNetwork] } else if let cardNetwork = metadata.detectedCardNetworks.items.first { - currentCardNetworks = [cardNetwork] + currentlyAvailableCardNetworks = [cardNetwork] } else { - currentCardNetworks = [] + currentlyAvailableCardNetworks = [] } - currentCardNetworks = currentCardNetworks?.filter { $0.displayName != "Unknown" } + currentlyAvailableCardNetworks = currentlyAvailableCardNetworks?.filter { $0.displayName != "Unknown" } + cardNumberContainerView.cardNetworks = currentlyAvailableCardNetworks ?? [] - // Trigger the closure to inform the view of the update - onCurrentCardNetworksUpdated?(currentCardNetworks) - cardNumberContainerView.cardNetworks = currentCardNetworks ?? [] + if currentlyAvailableCardNetworks?.count ?? 0 < 2 { + self.alternativelySelectedCardNetwork = nil + DispatchQueue.main.async { + self.cardNumberContainerView.resetCardNetworkSelection() + self.handleCardNetworkDetection(self.currentlyAvailableCardNetworks?.first?.network) + } + } } private func image(from model: PrimerCardNetwork) -> UIImage? { diff --git a/Sources/PrimerSDK/Classes/PCI/User Interface/InternalCardComponentsManager.swift b/Sources/PrimerSDK/Classes/PCI/User Interface/InternalCardComponentsManager.swift index 7ea60278bf..85f62605ae 100644 --- a/Sources/PrimerSDK/Classes/PCI/User Interface/InternalCardComponentsManager.swift +++ b/Sources/PrimerSDK/Classes/PCI/User Interface/InternalCardComponentsManager.swift @@ -39,6 +39,7 @@ protocol InternalCardComponentsManagerProtocol { var expiryDateField: PrimerExpiryDateFieldView { get } var cvvField: PrimerCVVFieldView { get } var cardholderField: PrimerCardholderNameFieldView? { get } + var selectedCardNetwork: CardNetwork? { get } var delegate: InternalCardComponentsManagerDelegate { get } var customerId: String? { get } var merchantIdentifier: String? { get } @@ -61,6 +62,7 @@ internal class InternalCardComponentsManager: NSObject, InternalCardComponentsMa var expiryDateField: PrimerExpiryDateFieldView var cvvField: PrimerCVVFieldView var cardholderField: PrimerCardholderNameFieldView? + var selectedCardNetwork: CardNetwork? // Network selected by the customer in Co-Badged Cards feature var billingAddressFieldViews: [PrimerTextFieldView]? var isRequiringCVVInput: Bool var paymentMethodType: String @@ -236,7 +238,7 @@ and 4 characters for expiry year separated by '/'. diagnosticsId: UUID().uuidString) errors.append(err) - } else if !cvvField.cvv.isValidCVV(cardNetwork: CardNetwork(cardNumber: cardnumberField.cardnumber)) { + } else if !cvvField.cvv.isValidCVV(cardNetwork: selectedCardNetwork ?? CardNetwork(cardNumber: cardnumberField.cardnumber)) { let err = PrimerValidationError.invalidCvv( message: "CVV is not valid.", userInfo: .errorUserInfoDictionary(), @@ -292,7 +294,8 @@ and 4 characters for expiry year separated by '/'. cvv: self.cvvField.cvv, expirationMonth: expiryMonth, expirationYear: cardExpirationYear, - cardholderName: self.cardholderField?.cardholderName) + cardholderName: self.cardholderField?.cardholderName, + preferredNetwork: self.selectedCardNetwork?.rawValue) return cardPaymentInstrument } else if let configId = AppState.current.apiConfiguration?.getConfigId(for: self.primerPaymentMethodType.rawValue), diff --git a/Sources/PrimerSDK/Classes/PCI/User Interface/Root/PrimerCardFormViewController.swift b/Sources/PrimerSDK/Classes/PCI/User Interface/Root/PrimerCardFormViewController.swift index 69a6980a96..eaa207ebb3 100644 --- a/Sources/PrimerSDK/Classes/PCI/User Interface/Root/PrimerCardFormViewController.swift +++ b/Sources/PrimerSDK/Classes/PCI/User Interface/Root/PrimerCardFormViewController.swift @@ -44,10 +44,6 @@ class PrimerCardFormViewController: PrimerFormViewController { ) Analytics.Service.record(event: viewEvent) - formPaymentMethodTokenizationViewModel.onCurrentCardNetworksUpdated = { networks in - print("Current card networks: \(String(describing: networks))") - } - setupView() } diff --git a/Sources/PrimerSDK/Classes/PCI/User Interface/Text Fields/PrimerCustomTextField.swift b/Sources/PrimerSDK/Classes/PCI/User Interface/Text Fields/PrimerCustomTextField.swift index ca6bff2108..d775d6e148 100644 --- a/Sources/PrimerSDK/Classes/PCI/User Interface/Text Fields/PrimerCustomTextField.swift +++ b/Sources/PrimerSDK/Classes/PCI/User Interface/Text Fields/PrimerCustomTextField.swift @@ -25,7 +25,7 @@ class PrimerCustomFieldView: UIView { } private var textFieldStackView: UIStackView! // Stack view containing the text field and dropdown - var networksDropdownView: UIView? // Dropdown view for displaying available card networks + private var networksDropdownView: UIView? // Dropdown view for displaying available card networks private var presentationButton: UIButton! // Button used to trigger menu programmatically // Callback triggered when a card network is selected @@ -68,7 +68,7 @@ class PrimerCustomFieldView: UIView { private let errorLabel = UILabel() // Label to display error messages private let topPlaceholderLabel = UILabel() // Label for the placeholder text private let rightImageViewContainer = UIView() // Container for right-side image view - private let rightImageView = UIImageView() // Right image view + let rightImageView = UIImageView() // Right image view private let bottomLine = UIView() // Line below the text field private var theme: PrimerThemeProtocol = DependencyContainer.resolve() // Theme for styling @@ -88,6 +88,19 @@ class PrimerCustomFieldView: UIView { constrainVerticalStackView() } + // Function to reset the card network selection to the initial state + func resetCardNetworkSelection() { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + self.selectedCardNetwork = nil + self.networkIconImageView?.image = self.cardNetworks.first?.network.icon // Reset to the first available icon + self.rightImageView.image = self.cardNetworks.first?.network.icon + // Update visibility based on card network count + self.updateNetworksDropdownViewVisibility() + } + } + // Sets up the main vertical stack view private func setupVerticalStackView() { addSubview(verticalStackView) @@ -155,13 +168,26 @@ class PrimerCustomFieldView: UIView { iconAndChevronStack.alignment = .center iconAndChevronStack.spacing = 4 - // Creates and adds the network icon - let networkIconImageView = UIImageView(image: cardNetworks.first?.network.icon) - self.networkIconImageView = networkIconImageView - networkIconImageView.contentMode = .scaleAspectFit - iconAndChevronStack.addArrangedSubview(networkIconImageView) + // Creates and configures the network icon container + let networkIconContainer = UIView() + networkIconImageView = UIImageView(image: cardNetworks.first?.network.icon) + networkIconImageView?.contentMode = .scaleAspectFit + + // Adds the network icon to the container + networkIconContainer.addSubview(networkIconImageView!) + networkIconImageView?.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + networkIconImageView!.topAnchor.constraint(equalTo: networkIconContainer.topAnchor, constant: 6), + networkIconImageView!.bottomAnchor.constraint(equalTo: networkIconContainer.bottomAnchor, constant: -6), + networkIconImageView!.leadingAnchor.constraint(equalTo: networkIconContainer.leadingAnchor), + networkIconImageView!.trailingAnchor.constraint(equalTo: networkIconContainer.trailingAnchor), + networkIconImageView!.widthAnchor.constraint(equalTo: networkIconContainer.heightAnchor) + ]) - // Creates and adds the chevron icon + // Adds the network icon container to the stack + iconAndChevronStack.addArrangedSubview(networkIconContainer) + + // Creates and configures the chevron icon let chevronImageView = UIImageView(image: UIImage(systemName: "chevron.down", withConfiguration: UIImage.SymbolConfiguration(scale: .small))) self.chevronImageView = chevronImageView chevronImageView.contentMode = .scaleAspectFit @@ -208,8 +234,8 @@ class PrimerCustomFieldView: UIView { } let menu = UIMenu(options: .singleSelection, children: uiActions) - presentationButton.menu = menu - presentationButton.showsMenuAsPrimaryAction = true + presentationButton?.menu = menu + presentationButton?.showsMenuAsPrimaryAction = true } // Sets constraints for the dropdown view and presentation button @@ -227,6 +253,11 @@ class PrimerCustomFieldView: UIView { // Updates dropdown visibility based on the number of card networks private func updateNetworksDropdownViewVisibility() { + + if #available(iOS 15.0, *) { + setupUIMenuForButton() + } + if cardNetworks.count > 1 { if networksDropdownView == nil { setupNetworksDropdownView() @@ -292,6 +323,7 @@ class PrimerCustomFieldView: UIView { let action = UIAlertAction(title: network.displayName, style: .default) { _ in self.selectedCardNetwork = network self.networkIconImageView?.image = network.network.icon + self.rightImageView.image = network.network.icon self.onCardNetworkSelected?(network) } action.setValue(network.network.icon?.withRenderingMode(.alwaysOriginal), forKey: "image") From 021b8f60abd3a5bfb8fdcf7d8bdfa4f17d2d38ed Mon Sep 17 00:00:00 2001 From: Boris Nikolic Date: Thu, 21 Nov 2024 12:38:49 +0100 Subject: [PATCH 06/12] Implement business logic --- Debug App/Podfile.lock | 2 +- ...dFormPaymentMethodTokenizationViewModel.swift | 5 ++++- .../InternalCardComponentsManager.swift | 16 ++++++++-------- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/Debug App/Podfile.lock b/Debug App/Podfile.lock index dbd24609e9..6b00187e0e 100644 --- a/Debug App/Podfile.lock +++ b/Debug App/Podfile.lock @@ -42,4 +42,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: fa17ead44d40b0b09abc2f30a5cc3d8aefe389e1 -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2 diff --git a/Sources/PrimerSDK/Classes/PCI/Tokenization View Models/FormsTokenizationViewModel/CardFormPaymentMethodTokenizationViewModel.swift b/Sources/PrimerSDK/Classes/PCI/Tokenization View Models/FormsTokenizationViewModel/CardFormPaymentMethodTokenizationViewModel.swift index 3401a50410..0065962792 100644 --- a/Sources/PrimerSDK/Classes/PCI/Tokenization View Models/FormsTokenizationViewModel/CardFormPaymentMethodTokenizationViewModel.swift +++ b/Sources/PrimerSDK/Classes/PCI/Tokenization View Models/FormsTokenizationViewModel/CardFormPaymentMethodTokenizationViewModel.swift @@ -134,6 +134,7 @@ class CardFormPaymentMethodTokenizationViewModel: PaymentMethodTokenizationViewM self.alternativelySelectedCardNetwork = cardNetwork.network self.rawCardData.cardNetwork = cardNetwork.network self.rawDataManager?.rawData = self.rawCardData // TODO: (BNI) This does not work for unknown reason + self.cardComponentsManager.selectedCardNetwork = cardNetwork.network // Select payment method based on the detected card network let clientSessionActionsModule: ClientSessionActionsProtocol = ClientSessionActionsModule() @@ -141,7 +142,7 @@ class CardFormPaymentMethodTokenizationViewModel: PaymentMethodTokenizationViewM clientSessionActionsModule.selectPaymentMethodIfNeeded(self.config.type, cardNetwork: cardNetwork.network.rawValue) } .done { - self.updateButtonUI() + self.configurePayButton(cardNetwork: cardNetwork.network) } .catch { _ in } } @@ -696,6 +697,8 @@ class CardFormPaymentMethodTokenizationViewModel: PaymentMethodTokenizationViewM } } + + // TODO: (BNI) This one is not being used, but it could be used for new UI func configurePayButton(cardNetwork: CardNetwork?) { var amount: Int = AppState.current.amount ?? 0 diff --git a/Sources/PrimerSDK/Classes/PCI/User Interface/InternalCardComponentsManager.swift b/Sources/PrimerSDK/Classes/PCI/User Interface/InternalCardComponentsManager.swift index 86671ea031..5ed7c36be6 100644 --- a/Sources/PrimerSDK/Classes/PCI/User Interface/InternalCardComponentsManager.swift +++ b/Sources/PrimerSDK/Classes/PCI/User Interface/InternalCardComponentsManager.swift @@ -290,20 +290,20 @@ and 4 characters for expiry year separated by '/'. if isRequiringCVVInput { - let cardPaymentInstrument = CardPaymentInstrument(number: self.cardnumberField.cardnumber, - cvv: self.cvvField.cvv, + let cardPaymentInstrument = CardPaymentInstrument(number: cardnumberField.cardnumber, + cvv: cvvField.cvv, expirationMonth: expiryMonth, expirationYear: cardExpirationYear, - cardholderName: self.cardholderField?.cardholderName, - preferredNetwork: self.selectedCardNetwork?.rawValue) + cardholderName: cardholderField?.cardholderName, + preferredNetwork: selectedCardNetwork?.rawValue) return cardPaymentInstrument - } else if let configId = AppState.current.apiConfiguration?.getConfigId(for: self.primerPaymentMethodType.rawValue), - let cardholderName = self.cardholderField?.cardholderName { + } else if let configId = AppState.current.apiConfiguration?.getConfigId(for: primerPaymentMethodType.rawValue), + let cardholderName = cardholderField?.cardholderName { let cardOffSessionPaymentInstrument = CardOffSessionPaymentInstrument(paymentMethodConfigId: configId, - paymentMethodType: self.primerPaymentMethodType.rawValue, - number: self.cardnumberField.cardnumber, + paymentMethodType: primerPaymentMethodType.rawValue, + number: cardnumberField.cardnumber, expirationMonth: expiryMonth, expirationYear: cardExpirationYear, cardholderName: cardholderName) From 233a6501b9fdceb642a07bd81cde2178b1944182 Mon Sep 17 00:00:00 2001 From: Boris Nikolic Date: Thu, 21 Nov 2024 14:26:17 +0100 Subject: [PATCH 07/12] Perform UI changes in the main queue --- .../Extensions & Utilities/PrimerButton.swift | 29 ++++++++++--------- ...rmPaymentMethodTokenizationViewModel.swift | 2 -- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/Sources/PrimerSDK/Classes/Extensions & Utilities/PrimerButton.swift b/Sources/PrimerSDK/Classes/Extensions & Utilities/PrimerButton.swift index c6a5b1d49c..88ae1bdf5e 100644 --- a/Sources/PrimerSDK/Classes/Extensions & Utilities/PrimerButton.swift +++ b/Sources/PrimerSDK/Classes/Extensions & Utilities/PrimerButton.swift @@ -160,27 +160,28 @@ extension PrimerButton { func startAnimating() { if activityIndicator.isAnimating { return } - activityIndicator.startAnimating() - var buttonStates: [ActivityIndicatorButtonState] = [] - for state in [UIControl.State.disabled] { - let buttonState = ActivityIndicatorButtonState(state: state, title: title(for: state), image: image(for: state)) - buttonStates.append(buttonState) - setTitle("", for: state) - setImage(UIImage(), for: state) - } - self.activityIndicatorButtonStates = buttonStates DispatchQueue.main.async { + + self.activityIndicator.startAnimating() + var buttonStates: [ActivityIndicatorButtonState] = [] + for state in [UIControl.State.disabled] { + let buttonState = ActivityIndicatorButtonState(state: state, title: self.title(for: state), image: self.image(for: state)) + buttonStates.append(buttonState) + self.setTitle("", for: state) + self.setImage(UIImage(), for: state) + } + self.activityIndicatorButtonStates = buttonStates self.isEnabled = false } } func stopAnimating() { - activityIndicator.stopAnimating() - for buttonState in activityIndicatorButtonStates { - setTitle(buttonState.title, for: buttonState.state) - setImage(buttonState.image, for: buttonState.state) - } DispatchQueue.main.async { + self.activityIndicator.stopAnimating() + for buttonState in self.activityIndicatorButtonStates { + self.setTitle(buttonState.title, for: buttonState.state) + self.setImage(buttonState.image, for: buttonState.state) + } self.isEnabled = true } } diff --git a/Sources/PrimerSDK/Classes/PCI/Tokenization View Models/FormsTokenizationViewModel/CardFormPaymentMethodTokenizationViewModel.swift b/Sources/PrimerSDK/Classes/PCI/Tokenization View Models/FormsTokenizationViewModel/CardFormPaymentMethodTokenizationViewModel.swift index 0065962792..a3f90544c0 100644 --- a/Sources/PrimerSDK/Classes/PCI/Tokenization View Models/FormsTokenizationViewModel/CardFormPaymentMethodTokenizationViewModel.swift +++ b/Sources/PrimerSDK/Classes/PCI/Tokenization View Models/FormsTokenizationViewModel/CardFormPaymentMethodTokenizationViewModel.swift @@ -697,8 +697,6 @@ class CardFormPaymentMethodTokenizationViewModel: PaymentMethodTokenizationViewM } } - - // TODO: (BNI) This one is not being used, but it could be used for new UI func configurePayButton(cardNetwork: CardNetwork?) { var amount: Int = AppState.current.amount ?? 0 From ceca053fd757a365f20458e6052bbc333a313cf0 Mon Sep 17 00:00:00 2001 From: Boris Nikolic Date: Wed, 4 Dec 2024 14:56:33 +0100 Subject: [PATCH 08/12] Add surcharge options to Setting screen --- .../Base.lproj/Main.storyboard | 10 +-- .../Sources/Model/CreateClientToken.swift | 71 +++++++++++++++---- .../Merchant Helpers/MerchantHelpers.swift | 3 +- ...hantSessionAndSettingsViewController.swift | 34 ++++++--- ...rmPaymentMethodTokenizationViewModel.swift | 38 +++++----- 5 files changed, 113 insertions(+), 43 deletions(-) diff --git a/Debug App/Resources/Localized Views/Base.lproj/Main.storyboard b/Debug App/Resources/Localized Views/Base.lproj/Main.storyboard index ad63005ba4..2ec5fd1e0c 100644 --- a/Debug App/Resources/Localized Views/Base.lproj/Main.storyboard +++ b/Debug App/Resources/Localized Views/Base.lproj/Main.storyboard @@ -1235,13 +1235,13 @@ -