Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement security code component UI #489

Merged
merged 6 commits into from
Oct 26, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/scripts/lintEditedFiles.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ if EDITED_FILES=$(git diff HEAD origin/main --name-only --diff-filter=d | grep "
if [ -z "$EDITED_FILES" ]; then
echo "No edited .swift files found."
else
swiftlint lint $EDITED_FILES | sed -E 's/^(.*):([0-9]+):([0-9]+): (warning|error|[^:]+): (.*)/::\4 title=Lint error,file=\1,line=\2,col=\3::\5\n\1:\2:\3/'
swiftlint lint $EDITED_FILES | sed -E -n 's/^(.*):([0-9]+):([0-9]+): error: (.*)/::error file=\1,line=\2,col=\3::\4\n\1:\2:\3/p'
fi
else
echo "No changes in .swift files found."
Expand Down
4 changes: 4 additions & 0 deletions Source/Core/Constants/AccessibilityIdentifiers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,8 @@ public enum AccessibilityIdentifiers {
static public let phoneNumber = "PhoneNumberInput"
}

public enum SecurityCodeComponent {
/// Identify security code component text field
okhan-okbay-cko marked this conversation as resolved.
Show resolved Hide resolved
static public let textField = "SecurityCodeTextField"
}
}
2 changes: 1 addition & 1 deletion Source/UI/PaymentForm/View/PaymentHeaderView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import UIKit
import Checkout

public final class PaymentHeaderView: UIView {
final class PaymentHeaderView: UIView {
okhan-okbay-cko marked this conversation as resolved.
Show resolved Hide resolved
private var style: PaymentHeaderCellStyle?

private let supportedSchemes: [Card.Scheme]
Expand Down
5 changes: 3 additions & 2 deletions Source/UI/PaymentForm/View/SecurityCodeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,11 @@ extension SecurityCodeView: TextFieldViewDelegate {
}

func textFieldShouldChangeCharactersIn(textField: UITextField, replacementString string: String) {
guard let style = style else { return }
codeInputView.updateBorderColor(with: style.textfield.borderStyle.focusColor)
viewModel.updateInput(to: textField.text)
delegate?.update(securityCode: viewModel.cvv)

guard let style = style else { return }
codeInputView.updateBorderColor(with: style.textfield.borderStyle.focusColor)
}

func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//
// DefaultSecurityCodeFormStyle+Extension.swift
//
//
// Created by Okhan Okbay on 05/10/2023.
//

import Foundation

extension DefaultSecurityCodeFormStyle {
init(securityCodeComponentStyle: SecurityCodeComponentStyle) {
self.isMandatory = false
self.backgroundColor = .clear
self.title = nil
self.hint = nil
self.mandatory = nil
self.error = nil
self.textfield = DefaultTextField(textAlignment: securityCodeComponentStyle.textAlignment,
isHidden: false,
isSupportingNumericKeyboard: true,
text: securityCodeComponentStyle.text,
placeholder: securityCodeComponentStyle.placeholder,
textColor: securityCodeComponentStyle.textColor,
backgroundColor: .clear,
tintColor: securityCodeComponentStyle.tintColor,
width: .zero,
height: .zero,
font: securityCodeComponentStyle.font,
borderStyle: DefaultBorderStyle(cornerRadius: .zero,
borderWidth: .zero,
normalColor: .clear,
focusColor: .clear,
errorColor: .clear))

}
}
67 changes: 67 additions & 0 deletions Source/UI/SecurityCodeComponent/SecurityCodeComponent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
//
// SecurityCodeComponent.swift
//
//
// Created by Okhan Okbay on 26/09/2023.
//

import Checkout
import UIKit

public final class SecurityCodeComponent: UIView {
okhan-okbay-cko marked this conversation as resolved.
Show resolved Hide resolved
private var view: SecurityCodeView!

private var configuration: SecurityCodeComponentConfiguration!
private var isSecurityCodeValid: ((Bool) -> Void)!
private var cardValidator: CardValidating!

override init(frame: CGRect) {
super.init(frame: frame)
}

required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
}

extension SecurityCodeComponent {
/**
Method to configure SecurityCodeComponent and get the validation updates
- configuration: See SecurityCodeComponentConfiguration documentation for the details
- isSecurityCodeValid: A boolean value that indicates if the security code that was input by the user is valid or not.
If a cardScheme is passed in the configuration, validation is being evaluated for the scheme. If no cardScheme is passed, then the security code is considered as valid for 3 and 4 digits.
*/
public func configure(with configuration: SecurityCodeComponentConfiguration,
okhan-okbay-cko marked this conversation as resolved.
Show resolved Hide resolved
isSecurityCodeValid: @escaping (Bool) -> Void) {
self.configuration = configuration
self.isSecurityCodeValid = isSecurityCodeValid

self.cardValidator = CardValidator(environment: configuration.environment.checkoutEnvironment)

let viewModel = SecurityCodeViewModel(cardValidator: cardValidator)
if let initialCardScheme = configuration.cardScheme {
viewModel.updateScheme(to: initialCardScheme)
}

let view = SecurityCodeView(viewModel: viewModel)
view.update(style: DefaultSecurityCodeFormStyle(securityCodeComponentStyle: configuration.style))
view.accessibilityIdentifier = AccessibilityIdentifiers.SecurityCodeComponent.textField
view.delegate = self

view.frame = bounds
view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
addSubview(view)

self.view = view
}
}

extension SecurityCodeComponent: SecurityCodeViewDelegate {
func update(securityCode: String) {
guard !securityCode.isEmpty else {
isSecurityCodeValid(false)
return
}
isSecurityCodeValid(cardValidator.isValid(cvv: securityCode, for: configuration.cardScheme ?? .unknown))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
//
// SecurityCodeConfiguration.swift
//
//
// Created by Okhan Okbay on 05/10/2023.
//

import Checkout
import UIKit

/**
Configures and styles the SecurityCodeComponent

- apiKey: The API Key you receive from checkout.com
- environment: Production or sandbox
- cardScheme: Optional card scheme
- If provided, card scheme's validation rules apply (e.g. VISA = 3 digits, American Express = 4 digits etc.)
- If not provided, security code is treated as valid for 3 and 4 digits
- style: Security Code Component wraps a text field in a secure way.
To style the inner properties like font, textColor etc, you must alter the style.
*/

public struct SecurityCodeComponentConfiguration {
let apiKey: String
let environment: Environment
public var style: SecurityCodeComponentStyle
public var cardScheme: Card.Scheme?

public init(apiKey: String,
environment: Environment,
style: SecurityCodeComponentStyle? = nil,
cardScheme: Card.Scheme? = nil) {
self.apiKey = apiKey
self.environment = environment
self.cardScheme = cardScheme

if let style = style {
self.style = style
} else {
self.style = .init(text: .init(),
font: FramesUIStyle.Font.inputLabel,
textAlignment: .natural,
textColor: FramesUIStyle.Color.textPrimary,
tintColor: FramesUIStyle.Color.textPrimary,
placeholder: nil)
}
}
}
38 changes: 38 additions & 0 deletions Source/UI/SecurityCodeComponent/SecurityCodeComponentStyle.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//
// SecurityCodeComponentStyle.swift
//
//
// Created by Okhan Okbay on 05/10/2023.
//

import UIKit

/**
All the other UI relevant changes shall be done on the UIView instance of your project.
The reason that these properties are presented to be modified here is that
they are embedded in SecureDisplayView and shouldn't be reachable other than
via SecurityCodeComponentStyle.
*/

public struct SecurityCodeComponentStyle {
public let text: String
public let font: UIFont
public let textAlignment: NSTextAlignment
public let textColor: UIColor
public let tintColor: UIColor
public let placeholder: String?

public init(text: String,
font: UIFont,
textAlignment: NSTextAlignment,
textColor: UIColor,
tintColor: UIColor,
placeholder: String?) {
self.text = text
self.font = font
self.textAlignment = textAlignment
self.textColor = textColor
self.tintColor = tintColor
self.placeholder = placeholder
}
}
58 changes: 58 additions & 0 deletions Tests/UI/SecurityCodeComponent/SecurityCodeComponentTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
//
// SecurityCodeComponentTests.swift
//
//
// Created by Okhan Okbay on 20/10/2023.
//

@testable import Frames
import XCTest

final class SecurityCodeComponentTests: XCTestCase {
let sut = SecurityCodeComponent()
var mockconfig = SecurityCodeComponentConfiguration(apiKey: "some_api_key", environment: .sandbox)

func test_whenUpdateIsCalledWith_thenISSecurityCodeValidCalledWithCorrectResult() {
okhan-okbay-cko marked this conversation as resolved.
Show resolved Hide resolved
let testMatrix: [(scheme: Card.Scheme?, securityCode: String, expectedResult: Bool)] = [
(.visa, "", false),
(.visa, "1", false),
(.visa, "12", false),
(.visa, "123", true),
(.visa, "1234", false),
(.americanExpress, "", false),
(.americanExpress, "1", false),
(.americanExpress, "12", false),
(.americanExpress, "123", false),
(.americanExpress, "1234", true),
(.unknown, "", false),
(.unknown, "1", false),
(.unknown, "12", false),
(.unknown, "123", true),
(.unknown, "1234", true),
(nil, "", false),
(nil, "1", false),
(nil, "12", false),
(nil, "123", true),
(nil, "1234", true),
]

testMatrix.forEach { testData in
mockconfig.cardScheme = testData.scheme
verify(securityCode: testData.securityCode, expectedResult: testData.expectedResult)
}
}

func verify(securityCode: String,
expectedResult: Bool,
file: StaticString = #file,
line: UInt = #line) {
sut.configure(with: mockconfig) { [weak self] isSecurityCodeValid in
XCTAssertEqual(expectedResult,
isSecurityCodeValid,
"Security code: \(securityCode) Card Scheme: \(self?.mockconfig.cardScheme?.rawValue ?? "nil") \n Expected: \(expectedResult) but found: \(isSecurityCodeValid)",
file: file,
line: line)
}
sut.update(securityCode: securityCode)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ let tokenableTestCards: [TestCard] = [
]

/**
These are luhn numbers that are just 1 character less with the least character count of the relevant card schemes
These are luhn numbers that are just 1 character less than the min character count of the relevant card schemes
For example, Visa cards must be at least 13 characters and must start with 44.
So, we needed a luhn number that starts with 4 and is 12 digits.
To see that we check the character count beyond the luhn verifications.
Expand Down
Loading