diff --git a/StripePaymentSheet/StripePaymentSheet.xcodeproj/project.pbxproj b/StripePaymentSheet/StripePaymentSheet.xcodeproj/project.pbxproj index b2c4acde478..61e72cf064d 100644 --- a/StripePaymentSheet/StripePaymentSheet.xcodeproj/project.pbxproj +++ b/StripePaymentSheet/StripePaymentSheet.xcodeproj/project.pbxproj @@ -57,6 +57,22 @@ 3147CEC02CC080570067B5E4 /* LinkInMemoryCookieStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CEBD2CC080570067B5E4 /* LinkInMemoryCookieStore.swift */; }; 3147CEC12CC080570067B5E4 /* LinkCookieStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CEBC2CC080570067B5E4 /* LinkCookieStore.swift */; }; 3147CEC22CC080570067B5E4 /* LinkSecureCookieStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CEBE2CC080570067B5E4 /* LinkSecureCookieStore.swift */; }; + 3147CECA2CC1BF550067B5E4 /* LinkPaymentMethodPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CEC32CC1BF550067B5E4 /* LinkPaymentMethodPicker.swift */; }; + 3147CECB2CC1BF550067B5E4 /* LinkPaymentMethodPicker-CellContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CEC62CC1BF550067B5E4 /* LinkPaymentMethodPicker-CellContentView.swift */; }; + 3147CECC2CC1BF550067B5E4 /* LinkPaymentMethodPicker-Cell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CEC52CC1BF550067B5E4 /* LinkPaymentMethodPicker-Cell.swift */; }; + 3147CECD2CC1BF550067B5E4 /* LinkPaymentMethodPicker-Header.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CEC72CC1BF550067B5E4 /* LinkPaymentMethodPicker-Header.swift */; }; + 3147CECE2CC1BF550067B5E4 /* LinkPaymentMethodPicker-RadioButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CEC82CC1BF550067B5E4 /* LinkPaymentMethodPicker-RadioButton.swift */; }; + 3147CECF2CC1BF550067B5E4 /* LinkPaymentMethodPicker-AddButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CEC42CC1BF550067B5E4 /* LinkPaymentMethodPicker-AddButton.swift */; }; + 3147CED12CC1BF6E0067B5E4 /* LinkCardEditElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CED02CC1BF6E0067B5E4 /* LinkCardEditElement.swift */; }; + 3147CED82CC1BF860067B5E4 /* LinkVerificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CED62CC1BF860067B5E4 /* LinkVerificationViewController.swift */; }; + 3147CED92CC1BF860067B5E4 /* LinkVerificationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CED22CC1BF860067B5E4 /* LinkVerificationController.swift */; }; + 3147CEDA2CC1BF860067B5E4 /* LinkVerificationViewController-PresentationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CED72CC1BF860067B5E4 /* LinkVerificationViewController-PresentationController.swift */; }; + 3147CEDB2CC1BF860067B5E4 /* LinkVerificationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CED32CC1BF860067B5E4 /* LinkVerificationView.swift */; }; + 3147CEDC2CC1BF860067B5E4 /* LinkVerificationView-Header.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CED42CC1BF860067B5E4 /* LinkVerificationView-Header.swift */; }; + 3147CEDD2CC1BF860067B5E4 /* LinkVerificationView-LogoutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CED52CC1BF860067B5E4 /* LinkVerificationView-LogoutView.swift */; }; + 3147CEE12CC1BFA80067B5E4 /* LinkCardEditElementSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CEDE2CC1BFA80067B5E4 /* LinkCardEditElementSnapshotTests.swift */; }; + 3147CEE22CC1BFA80067B5E4 /* LinkPaymentMethodPickerSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CEDF2CC1BFA80067B5E4 /* LinkPaymentMethodPickerSnapshotTests.swift */; }; + 3147CEE32CC1BFA80067B5E4 /* LinkVerificationViewSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CEE02CC1BFA80067B5E4 /* LinkVerificationViewSnapshotTests.swift */; }; 31699A812BE183B30048677F /* DownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31699A802BE183B30048677F /* DownloadManager.swift */; }; 31699A832BE183D40048677F /* DownloadManagerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31699A822BE183D40048677F /* DownloadManagerTest.swift */; }; 316B33122B5F171C0008D2E5 /* UserDefaults+StripePaymentSheetTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 316B33112B5F171C0008D2E5 /* UserDefaults+StripePaymentSheetTest.swift */; }; @@ -437,6 +453,22 @@ 3147CEBC2CC080570067B5E4 /* LinkCookieStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkCookieStore.swift; sourceTree = ""; }; 3147CEBD2CC080570067B5E4 /* LinkInMemoryCookieStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkInMemoryCookieStore.swift; sourceTree = ""; }; 3147CEBE2CC080570067B5E4 /* LinkSecureCookieStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkSecureCookieStore.swift; sourceTree = ""; }; + 3147CEC32CC1BF550067B5E4 /* LinkPaymentMethodPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPaymentMethodPicker.swift; sourceTree = ""; }; + 3147CEC42CC1BF550067B5E4 /* LinkPaymentMethodPicker-AddButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LinkPaymentMethodPicker-AddButton.swift"; sourceTree = ""; }; + 3147CEC52CC1BF550067B5E4 /* LinkPaymentMethodPicker-Cell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LinkPaymentMethodPicker-Cell.swift"; sourceTree = ""; }; + 3147CEC62CC1BF550067B5E4 /* LinkPaymentMethodPicker-CellContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LinkPaymentMethodPicker-CellContentView.swift"; sourceTree = ""; }; + 3147CEC72CC1BF550067B5E4 /* LinkPaymentMethodPicker-Header.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LinkPaymentMethodPicker-Header.swift"; sourceTree = ""; }; + 3147CEC82CC1BF550067B5E4 /* LinkPaymentMethodPicker-RadioButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LinkPaymentMethodPicker-RadioButton.swift"; sourceTree = ""; }; + 3147CED02CC1BF6E0067B5E4 /* LinkCardEditElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkCardEditElement.swift; sourceTree = ""; }; + 3147CED22CC1BF860067B5E4 /* LinkVerificationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkVerificationController.swift; sourceTree = ""; }; + 3147CED32CC1BF860067B5E4 /* LinkVerificationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkVerificationView.swift; sourceTree = ""; }; + 3147CED42CC1BF860067B5E4 /* LinkVerificationView-Header.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LinkVerificationView-Header.swift"; sourceTree = ""; }; + 3147CED52CC1BF860067B5E4 /* LinkVerificationView-LogoutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LinkVerificationView-LogoutView.swift"; sourceTree = ""; }; + 3147CED62CC1BF860067B5E4 /* LinkVerificationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkVerificationViewController.swift; sourceTree = ""; }; + 3147CED72CC1BF860067B5E4 /* LinkVerificationViewController-PresentationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LinkVerificationViewController-PresentationController.swift"; sourceTree = ""; }; + 3147CEDE2CC1BFA80067B5E4 /* LinkCardEditElementSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkCardEditElementSnapshotTests.swift; sourceTree = ""; }; + 3147CEDF2CC1BFA80067B5E4 /* LinkPaymentMethodPickerSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPaymentMethodPickerSnapshotTests.swift; sourceTree = ""; }; + 3147CEE02CC1BFA80067B5E4 /* LinkVerificationViewSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkVerificationViewSnapshotTests.swift; sourceTree = ""; }; 3168698F2C61B0F5EC1240FE /* sl-SI */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "sl-SI"; path = "sl-SI.lproj/Localizable.strings"; sourceTree = ""; }; 31699A802BE183B30048677F /* DownloadManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadManager.swift; sourceTree = ""; }; 31699A822BE183D40048677F /* DownloadManagerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadManagerTest.swift; sourceTree = ""; }; @@ -854,6 +886,19 @@ path = CookieStore; sourceTree = ""; }; + 3147CEC92CC1BF550067B5E4 /* PaymentMethodPicker */ = { + isa = PBXGroup; + children = ( + 3147CEC32CC1BF550067B5E4 /* LinkPaymentMethodPicker.swift */, + 3147CEC42CC1BF550067B5E4 /* LinkPaymentMethodPicker-AddButton.swift */, + 3147CEC52CC1BF550067B5E4 /* LinkPaymentMethodPicker-Cell.swift */, + 3147CEC62CC1BF550067B5E4 /* LinkPaymentMethodPicker-CellContentView.swift */, + 3147CEC72CC1BF550067B5E4 /* LinkPaymentMethodPicker-Header.swift */, + 3147CEC82CC1BF550067B5E4 /* LinkPaymentMethodPicker-RadioButton.swift */, + ); + path = PaymentMethodPicker; + sourceTree = ""; + }; 319DFD940EAA6EA0A0F4E771 /* Services */ = { isa = PBXGroup; children = ( @@ -900,6 +945,7 @@ 31CC9B872CB5F74A00E84A38 /* Badge */, 31CC9B892CB5F74A00E84A38 /* NavigationBar */, 31CC9B8B2CB5F74A00E84A38 /* Notice */, + 3147CEC92CC1BF550067B5E4 /* PaymentMethodPicker */, 31CC9B942CB5F74A00E84A38 /* Toast */, ); path = Components; @@ -1246,6 +1292,12 @@ B02DD1BA93CC92A187051B2F /* LinkAccountContext.swift */, 3147CEBA2CC07E960067B5E4 /* LinkUtils.swift */, A1928BE9DFF116368B1A19DC /* LinkCookieKey.swift */, + 3147CED22CC1BF860067B5E4 /* LinkVerificationController.swift */, + 3147CED32CC1BF860067B5E4 /* LinkVerificationView.swift */, + 3147CED42CC1BF860067B5E4 /* LinkVerificationView-Header.swift */, + 3147CED52CC1BF860067B5E4 /* LinkVerificationView-LogoutView.swift */, + 3147CED62CC1BF860067B5E4 /* LinkVerificationViewController.swift */, + 3147CED72CC1BF860067B5E4 /* LinkVerificationViewController-PresentationController.swift */, ); path = Verification; sourceTree = ""; @@ -1491,6 +1543,7 @@ children = ( C71284B45BF3D3B487C1B99D /* InlineSignup */, C684CBDA487CC3E78676F52E /* LinkEmailElement.swift */, + 3147CED02CC1BF6E0067B5E4 /* LinkCardEditElement.swift */, ); path = Elements; sourceTree = ""; @@ -1635,6 +1688,9 @@ 31CC9B762CB5F69600E84A38 /* LinkURLGeneratorTests.swift */, 22E4212F4A865B5AB5D72F99 /* LinkPopupURLParserTests.swift */, 9872CF28C8CA1D2C5499B8C5 /* LinkURLGeneratorTests.swift */, + 3147CEDE2CC1BFA80067B5E4 /* LinkCardEditElementSnapshotTests.swift */, + 3147CEDF2CC1BFA80067B5E4 /* LinkPaymentMethodPickerSnapshotTests.swift */, + 3147CEE02CC1BFA80067B5E4 /* LinkVerificationViewSnapshotTests.swift */, ); path = Link; sourceTree = ""; @@ -1845,6 +1901,9 @@ 31CC9B792CB5F69600E84A38 /* LinkNavigationBarSnapshotTests.swift in Sources */, 31CC9B7D2CB5F69600E84A38 /* ButtonLinkSnapshotTests.swift in Sources */, 31CC9B7E2CB5F69600E84A38 /* LinkNoticeViewSnapshotTests.swift in Sources */, + 3147CEE12CC1BFA80067B5E4 /* LinkCardEditElementSnapshotTests.swift in Sources */, + 3147CEE22CC1BFA80067B5E4 /* LinkPaymentMethodPickerSnapshotTests.swift in Sources */, + 3147CEE32CC1BFA80067B5E4 /* LinkVerificationViewSnapshotTests.swift in Sources */, 31CC9B7F2CB5F69600E84A38 /* LinkPopupURLParserTests.swift in Sources */, 31CC9B802CB5F69600E84A38 /* LinkToastSnapshotTests.swift in Sources */, 31CC9B812CB5F69600E84A38 /* LinkInstantDebitMandateViewSnapshotTests.swift in Sources */, @@ -2040,6 +2099,12 @@ DFA10770E494AFB895BA4EE2 /* TextFieldElement+Card.swift in Sources */, B6B3481CBA798CF22EE8411A /* TextFieldElement+IBAN.swift in Sources */, F90B7028426261188B66C834 /* Error+PaymentSheet.swift in Sources */, + 3147CECA2CC1BF550067B5E4 /* LinkPaymentMethodPicker.swift in Sources */, + 3147CECB2CC1BF550067B5E4 /* LinkPaymentMethodPicker-CellContentView.swift in Sources */, + 3147CECC2CC1BF550067B5E4 /* LinkPaymentMethodPicker-Cell.swift in Sources */, + 3147CECD2CC1BF550067B5E4 /* LinkPaymentMethodPicker-Header.swift in Sources */, + 3147CECE2CC1BF550067B5E4 /* LinkPaymentMethodPicker-RadioButton.swift in Sources */, + 3147CECF2CC1BF550067B5E4 /* LinkPaymentMethodPicker-AddButton.swift in Sources */, FB653AA92B68F73344835A50 /* Intent.swift in Sources */, 253EED99635621AC0E788EBC /* IntentConfirmParams.swift in Sources */, 313F5F832B0BE5FD00BD98A9 /* Docs.docc in Sources */, @@ -2084,6 +2149,12 @@ 6A5997192BC88E28002A44CB /* InstantDebitsPaymentMethodElement.swift in Sources */, E5571A970EB9DFC4B690636F /* STPAnalyticsClient+PaymentSheet.swift in Sources */, 0B142FE21B861925B513143D /* STPApplePayContext+PaymentSheet.swift in Sources */, + 3147CED82CC1BF860067B5E4 /* LinkVerificationViewController.swift in Sources */, + 3147CED92CC1BF860067B5E4 /* LinkVerificationController.swift in Sources */, + 3147CEDA2CC1BF860067B5E4 /* LinkVerificationViewController-PresentationController.swift in Sources */, + 3147CEDB2CC1BF860067B5E4 /* LinkVerificationView.swift in Sources */, + 3147CEDC2CC1BF860067B5E4 /* LinkVerificationView-Header.swift in Sources */, + 3147CEDD2CC1BF860067B5E4 /* LinkVerificationView-LogoutView.swift in Sources */, 6103F2BC2BE45990002D67F8 /* SavedPaymentMethodManager.swift in Sources */, ED75C8F47475E4BE5D496C93 /* STPPaymentIntentShippingDetailsParams+PaymentSheet.swift in Sources */, 4313D6635F10EC460D2ED21E /* SavedPaymentMethodCollectionView.swift in Sources */, @@ -2091,6 +2162,7 @@ F4EA474D60D0889E7D48E1CF /* BankAccountInfoView.swift in Sources */, 057A899F4123F3716F2AC0FA /* USBankAccountPaymentMethodElement.swift in Sources */, 985DAA770BC0289D24A5999C /* AddressSearchResult.swift in Sources */, + 3147CED12CC1BF6E0067B5E4 /* LinkCardEditElement.swift in Sources */, 31CC9BA12CB5F93100E84A38 /* Button+Link.swift in Sources */, 9DEDA3E0FFF73F9275F5F8F0 /* AutoCompleteViewController.swift in Sources */, A4CD99B2032CBFA7F957B1B8 /* String+AutoComplete.swift in Sources */, diff --git a/StripePaymentSheet/StripePaymentSheet/Resources/Localizations/en.lproj/Localizable.strings b/StripePaymentSheet/StripePaymentSheet/Resources/Localizations/en.lproj/Localizable.strings index 48ff5e600fc..7a3c93c8998 100644 --- a/StripePaymentSheet/StripePaymentSheet/Resources/Localizations/en.lproj/Localizable.strings +++ b/StripePaymentSheet/StripePaymentSheet/Resources/Localizations/en.lproj/Localizable.strings @@ -100,12 +100,18 @@ /* Button text on a screen asking the user to approve a payment */ "Cancel and pay another way" = "Cancel and pay another way"; +/* Title for a button that allows the user to use a different email in the signup flow. */ +"Change email" = "Change email"; + /* TODO */ "Choose a payment method" = "Choose a payment method"; /* Accessibility label for the button to close the card scanner. */ "Close card scanner" = "Close card scanner"; +/* Text of a notification shown to the user when a login code is successfully sent via SMS. */ +"Code sent" = "Code sent"; + /* Title used for various UIs, including a button that confirms entered payment details or the selection of a payment method. */ "Confirm" = "Confirm"; @@ -124,9 +130,18 @@ /* Label for CPF/CPNJ (Brazil tax ID) field */ "CPF/CPNJ" = "CPF/CPNJ"; +/* Label for identifying the default payment method. */ +"Default" = "Default"; + /* Text for a button that allows manual entry of an address */ "Enter address manually" = "Enter address manually"; +/* Instructs the user to enter the code sent to their phone number in order to login to Link */ +"Enter the code sent to %@ to use Link to pay by default." = "Enter the code sent to %@ to use Link to pay by default."; + +/* Two factor authentication screen heading */ +"Enter your verification code" = "Enter your verification code"; + /* Label title for EPS Bank */ "EPS Bank" = "EPS Bank"; @@ -160,6 +175,9 @@ /* Title shown above a section containing payment methods that a customer can choose to pay with e.g. card, bank account, etc. */ "New payment method" = "New payment method"; +/* Text of a label for confirming an email address. E.g., 'Not user@example.com?' */ +"Not %@?" = "Not %@?"; + /* Countdown timer text on a screen asking the user to approve a payment */ "Open your UPI app to approve your payment within %@" = "Open your UPI app to approve your payment within %@"; @@ -201,9 +219,15 @@ e.g, 'Pay faster at Example, Inc. and thousands of businesses.' */ /* US Bank Account copy title for Mobile payment element form */ "Pay with your bank account in just a few steps." = "Pay with your bank account in just a few steps."; +/* Label for a section displaying payment details. */ +"Payment" = "Payment"; + /* Text on a screen that indicates a payment has failed */ "Payment failed" = "Payment failed"; +/* Title for a section listing one or more payment methods. */ +"Payment methods" = "Payment methods"; + /* Text on a screen that indicates a payment has failed informing the user we are asking the user to try a different payment method */ "Please go back and select another payment method" = "Please go back and select another payment method"; @@ -222,6 +246,9 @@ e.g, 'Pay faster at Example, Inc. and thousands of businesses.' */ /* Title shown above a view containing a customer's payment method that they can delete */ "Remove payment method" = "Remove payment method"; +/* Label for a button that re-sends the a login code when tapped */ +"Resend code" = "Resend code"; + /* A button used for saving a new payment method */ "Save" = "Save"; @@ -259,6 +286,9 @@ to be saved and used in future checkout sessions. */ /* Title shown above a carousel containing the customer's payment methods */ "Select your payment method" = "Select your payment method"; +/* Label of a checkbox that when checked makes a payment method as the default one. */ +"Set as default payment method" = "Set as default payment method"; + /* Label of a button displayed below a payment method form. Tapping the button sets the payment method up for future use */ "Set up" = "Set up"; @@ -269,6 +299,9 @@ is not supported by the merchant */ /* Accessibility label for an action or a button that shows a menu. */ "Show menu" = "Show menu"; +/* Two factor authentication screen heading */ +"Sign in to your Link account" = "Sign in to your Link account"; + /* Subtitle shown on a button allowing a user to select to pay with Link. */ "Simple, secure one-click payments" = "Simple, secure one-click payments"; @@ -281,6 +314,12 @@ is not supported by the merchant */ /* Prompt for microdeposit verification before completing purchase with merchant. %@ will be replaced by merchant business name */ "Stripe will deposit $0.01 to your account in 1-2 business days. Then you’ll get an email with instructions to complete payment to %@." = "Stripe will deposit $0.01 to your account in 1-2 business days. Then you’ll get an email with instructions to complete payment to %@."; +/* Accessibility hint to tell the user that they can tap to hide additional content. */ +"Tap to close" = "Tap to close"; + +/* Accessibility hint to tell the user that they can tap to reveal additional content. */ +"Tap to expand" = "Tap to expand"; + /* An error message. */ "The IBAN you entered is incomplete." = "The IBAN you entered is incomplete."; @@ -296,6 +335,9 @@ is not supported by the merchant */ /* Error message shown when the user enters an incorrect verification code too many times. */ "Too many attempts. Please try again in a few minutes." = "Too many attempts. Please try again in a few minutes."; +/* Label shown when a payment method cannot be used for the current transaction. */ +"Unavailable for this purchase" = "Unavailable for this purchase"; + /* Title for a button that when tapped, updates a card brand. */ "Update" = "Update"; @@ -306,6 +348,9 @@ the heading the screen itself. */ /* Title for a screen for updating a card brand. */ "Update card brand" = "Update card brand"; +/* Two factor authentication screen heading */ +"Use your saved info to check out faster" = "Use your saved info to check out faster"; + /* Text shown on a button that displays a customer's default saved payment method. When tapped, it opens a screen that shows all of the customer's saved payment methods. */ "View more" = "View more"; diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/PaymentMethodPicker/LinkPaymentMethodPicker-AddButton.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/PaymentMethodPicker/LinkPaymentMethodPicker-AddButton.swift new file mode 100644 index 00000000000..3bb072a6432 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/PaymentMethodPicker/LinkPaymentMethodPicker-AddButton.swift @@ -0,0 +1,99 @@ +// +// LinkPaymentMethodPicker-AddButton.swift +// StripePaymentSheet +// +// Created by Ramon Torres on 10/20/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore +import UIKit + +extension LinkPaymentMethodPicker { + + final class AddButton: UIControl { + struct Constants { + static let iconSize: CGSize = .init(width: 24, height: 24) + } + + private let iconView: UIImageView = UIImageView(image: Image.icon_add_bordered.makeImage(template: true)) + + private lazy var textLabel: UILabel = { + let label = UILabel() + label.text = String.Localized.add_a_payment_method + label.numberOfLines = 0 + label.textColor = tintColor + label.font = LinkUI.font(forTextStyle: .bodyEmphasized) + label.adjustsFontForContentSizeCategory = true + return label + }() + + override var isHighlighted: Bool { + didSet { + update() + } + } + + init() { + super.init(frame: .zero) + + isAccessibilityElement = true + accessibilityTraits = .button + accessibilityLabel = textLabel.text + directionalLayoutMargins = Cell.Constants.margins + + setupUI() + update() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + if self.point(inside: point, with: event) { + return self + } + + return nil + } + + override func tintColorDidChange() { + super.tintColorDidChange() + self.textLabel.textColor = tintColor + } + + private func setupUI() { + let stackView = UIStackView(arrangedSubviews: [iconView, textLabel]) + stackView.spacing = Cell.Constants.contentSpacing + stackView.alignment = .center + stackView.translatesAutoresizingMaskIntoConstraints = false + + addSubview(stackView) + + NSLayoutConstraint.activate([ + iconView.widthAnchor.constraint(equalToConstant: Constants.iconSize.width), + iconView.heightAnchor.constraint(equalToConstant: Constants.iconSize.height), + stackView.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor), + stackView.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor), + stackView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), + ]) + } + + private func update() { + if isHighlighted { + iconView.alpha = 0.7 + textLabel.alpha = 0.7 + backgroundColor = .linkControlHighlight + } else { + iconView.alpha = 1 + textLabel.alpha = 1 + backgroundColor = .clear + } + } + + } + +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/PaymentMethodPicker/LinkPaymentMethodPicker-Cell.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/PaymentMethodPicker/LinkPaymentMethodPicker-Cell.swift new file mode 100644 index 00000000000..6a2a47fa3bc --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/PaymentMethodPicker/LinkPaymentMethodPicker-Cell.swift @@ -0,0 +1,280 @@ +// +// LinkPaymentMethodPicker-Cell.swift +// StripePaymentSheet +// +// Created by Ramon Torres on 10/19/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore +import UIKit + +protocol LinkPaymentMethodPickerCellDelegate: AnyObject { + func savedPaymentPickerCellDidSelect(_ cell: LinkPaymentMethodPicker.Cell) + func savedPaymentPickerCell(_ cell: LinkPaymentMethodPicker.Cell, didTapMenuButton button: UIButton) +} + +extension LinkPaymentMethodPicker { + + final class Cell: UIControl { + struct Constants { + static let margins = NSDirectionalEdgeInsets(top: 16, leading: 20, bottom: 16, trailing: 20) + static let contentSpacing: CGFloat = 12 + static let contentIndentation: CGFloat = 34 + static let menuSpacing: CGFloat = 8 + static let menuButtonSize: CGSize = .init(width: 24, height: 24) + static let separatorHeight: CGFloat = 1 + static let iconViewSize: CGSize = .init(width: 14, height: 20) + static let disabledContentAlpha: CGFloat = 0.5 + } + + override var isHighlighted: Bool { + didSet { + setNeedsLayout() + } + } + + override var isSelected: Bool { + didSet { + setNeedsLayout() + } + } + + var paymentMethod: ConsumerPaymentDetails? { + didSet { + update() + } + } + + var isLoading: Bool = false { + didSet { + if isLoading != oldValue { + update() + } + } + } + + var isSupported: Bool = true { + didSet { + if isSupported != oldValue { + update() + } + } + } + + weak var delegate: LinkPaymentMethodPickerCellDelegate? + + private let radioButton = RadioButton() + + private let contentView = CellContentView() + + private let activityIndicator = ActivityIndicator() + + private let defaultBadge = LinkBadgeView( + type: .neutral, + text: STPLocalizedString("Default", "Label for identifying the default payment method.") + ) + + private let alertIconView: UIImageView = { + let iconView = UIImageView() + iconView.contentMode = .scaleAspectFit + iconView.image = Image.icon_link_error.makeImage(template: true) + iconView.tintColor = .linkDangerForeground + return iconView + }() + + private let unavailableBadge = LinkBadgeView(type: .error, text: STPLocalizedString( + "Unavailable for this purchase", + "Label shown when a payment method cannot be used for the current transaction." + )) + + private lazy var menuButton: UIButton = { + let button = UIButton(type: .system) + button.setImage(Image.icon_menu.makeImage(), for: .normal) + button.addTarget(self, action: #selector(onMenuButtonTapped(_:)), for: .touchUpInside) + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + /// The menu button frame for hit-testing purposes. + private var menuButtonFrame: CGRect { + let originalFrame = menuButton.convert(menuButton.bounds, to: self) + + let targetSize = CGSize(width: 44, height: 44) + + return CGRect( + x: originalFrame.midX - (targetSize.width / 2), + y: originalFrame.midY - (targetSize.height / 2), + width: targetSize.width, + height: targetSize.height + ) + } + + private let separator: UIView = { + let separator = UIView() + separator.backgroundColor = .linkControlBorder + separator.translatesAutoresizingMaskIntoConstraints = false + return separator + }() + + override init(frame: CGRect) { + super.init(frame: frame) + + isAccessibilityElement = true + directionalLayoutMargins = Constants.margins + + setupUI() + addGestureRecognizer(UILongPressGestureRecognizer(target: self, action: #selector(onCellLongPressed))) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupUI() { + let rightStackView = UIStackView(arrangedSubviews: [defaultBadge, alertIconView, activityIndicator, menuButton]) + rightStackView.spacing = LinkUI.smallContentSpacing + rightStackView.distribution = .equalSpacing + rightStackView.alignment = .center + + let stackView = UIStackView(arrangedSubviews: [contentView, rightStackView]) + stackView.spacing = Constants.contentSpacing + stackView.distribution = .equalSpacing + stackView.alignment = .center + + let container = UIStackView(arrangedSubviews: [stackView, unavailableBadge]) + container.axis = .vertical + container.spacing = Constants.contentSpacing + container.distribution = .equalSpacing + container.alignment = .leading + container.translatesAutoresizingMaskIntoConstraints = false + addSubview(container) + + radioButton.translatesAutoresizingMaskIntoConstraints = false + addSubview(radioButton) + + addSubview(separator) + + NSLayoutConstraint.activate([ + // Radio button + radioButton.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), + radioButton.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + + // Menu button + menuButton.widthAnchor.constraint(equalToConstant: Constants.menuButtonSize.width), + menuButton.heightAnchor.constraint(equalToConstant: Constants.menuButtonSize.height), + + // Loader + activityIndicator.widthAnchor.constraint(equalToConstant: Constants.menuButtonSize.width), + activityIndicator.heightAnchor.constraint(equalToConstant: Constants.menuButtonSize.height), + + // Icon + alertIconView.widthAnchor.constraint(equalToConstant: Constants.iconViewSize.width), + alertIconView.heightAnchor.constraint(equalToConstant: Constants.iconViewSize.height), + + // Container + container.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor), + container.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor), + container.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor, constant: Constants.contentIndentation), + container.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), + // Make stackView fill the container + stackView.trailingAnchor.constraint(equalTo: container.trailingAnchor), + + // Separator + separator.heightAnchor.constraint(equalToConstant: Constants.separatorHeight), + separator.bottomAnchor.constraint(equalTo: bottomAnchor), + separator.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), + separator.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), + ]) + } + + private func update() { + contentView.paymentMethod = paymentMethod + updateAccessibilityContent() + + guard let paymentMethod = paymentMethod else { + return + } + + var hasExpired: Bool { + switch paymentMethod.details { + case .card(let card): + return card.hasExpired + case .bankAccount: + return false + case .unparsable: + return false + } + } + + defaultBadge.isHidden = isLoading || !paymentMethod.isDefault + alertIconView.isHidden = isLoading || !hasExpired + menuButton.isHidden = isLoading + contentView.alpha = isSupported ? 1 : Constants.disabledContentAlpha + unavailableBadge.isHidden = isSupported + + if isLoading { + activityIndicator.startAnimating() + } else { + activityIndicator.stopAnimating() + } + } + + override func layoutSubviews() { + super.layoutSubviews() + radioButton.isOn = isSelected + backgroundColor = isHighlighted ? .linkControlHighlight : .clear + } + + private func updateAccessibilityContent() { + guard let paymentMethod = paymentMethod else { + return + } + + accessibilityIdentifier = "Stripe.Link.PaymentMethodPickerCell" + accessibilityLabel = paymentMethod.accessibilityDescription + accessibilityCustomActions = [ + UIAccessibilityCustomAction( + name: String.Localized.show_menu, + target: self, + selector: #selector(onShowMenuAction(_:)) + ), + ] + } + + @objc func onShowMenuAction(_ sender: UIAccessibilityCustomAction) { + onMenuButtonTapped(menuButton) + } + + @objc func onMenuButtonTapped(_ sender: UIButton) { + delegate?.savedPaymentPickerCell(self, didTapMenuButton: sender) + } + + @objc func onCellLongPressed() { + delegate?.savedPaymentPickerCell(self, didTapMenuButton: menuButton) + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + if menuButtonFrame.contains(point) { + return menuButton + } + + return bounds.contains(point) ? self : nil + } + + override func touchesEnded(_ touches: Set, with event: UIEvent?) { + super.touchesEnded(touches, with: event) + + guard let touchLocation = touches.first?.location(in: self) else { + return + } + + if bounds.contains(touchLocation) { + delegate?.savedPaymentPickerCellDidSelect(self) + } + } + + } + +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/PaymentMethodPicker/LinkPaymentMethodPicker-CellContentView.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/PaymentMethodPicker/LinkPaymentMethodPicker-CellContentView.swift new file mode 100644 index 00000000000..5fe2fe5bcc5 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/PaymentMethodPicker/LinkPaymentMethodPicker-CellContentView.swift @@ -0,0 +1,138 @@ +// +// LinkPaymentMethodPicker-CellContentView.swift +// StripePaymentSheet +// +// Created by Ramon Torres on 11/19/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripePaymentsUI +@_spi(STP) import StripeUICore +import UIKit + +extension LinkPaymentMethodPicker { + + final class CellContentView: UIView { + struct Constants { + static let contentSpacing: CGFloat = 12 + static let iconSize: CGSize = CardBrandView.targetIconSize + static let maxFontSize: CGFloat = 20 + } + + var paymentMethod: ConsumerPaymentDetails? { + didSet { + switch paymentMethod?.details { + case .card: +// TODO(link): Needs refactor +// case .card(let card): +// cardBrandView.cardBrand = card.stpBrand + bankIconView.isHidden = true + cardBrandView.isHidden = false + primaryLabel.text = paymentMethod?.paymentSheetLabel + secondaryLabel.text = nil + secondaryLabel.isHidden = true + case .bankAccount(let bankAccount): + bankIconView.image = PaymentSheetImageLibrary.bankIcon(for: bankAccount.iconCode) + cardBrandView.isHidden = true + bankIconView.isHidden = false + primaryLabel.text = bankAccount.name + secondaryLabel.text = paymentMethod?.paymentSheetLabel + secondaryLabel.isHidden = false + case .none, .unparsable: + cardBrandView.isHidden = true + bankIconView.isHidden = true + primaryLabel.text = nil + secondaryLabel.text = nil + secondaryLabel.isHidden = true + } + } + } + + private lazy var bankIconView: UIImageView = { + let iconView = UIImageView() + iconView.contentMode = .scaleAspectFit + return iconView + }() + + private lazy var cardBrandView: CardBrandView = CardBrandView(centerHorizontally: true) + + private let primaryLabel: UILabel = { + let label = UILabel() + label.adjustsFontForContentSizeCategory = true + label.font = LinkUI.font(forTextStyle: .bodyEmphasized, maximumPointSize: Constants.maxFontSize) + label.textColor = .linkPrimaryText + return label + }() + + private let secondaryLabel: UILabel = { + let label = UILabel() + label.font = LinkUI.font(forTextStyle: .caption, maximumPointSize: Constants.maxFontSize) + label.textColor = .linkSecondaryText + return label + }() + + private lazy var iconContainerView: UIView = { + let view = UIView() + bankIconView.translatesAutoresizingMaskIntoConstraints = false + cardBrandView.translatesAutoresizingMaskIntoConstraints = false + + view.addSubview(bankIconView) + view.addSubview(cardBrandView) + + let cardBrandSize = cardBrandView.size(for: Constants.iconSize) + NSLayoutConstraint.activate([ + view.widthAnchor.constraint(equalToConstant: max(Constants.iconSize.width, cardBrandSize.width)), + view.heightAnchor.constraint(equalToConstant: max(Constants.iconSize.height, cardBrandSize.height)), + + bankIconView.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor), + bankIconView.topAnchor.constraint(greaterThanOrEqualTo: view.topAnchor), + bankIconView.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor), + bankIconView.bottomAnchor.constraint(lessThanOrEqualTo: view.bottomAnchor), + bankIconView.centerXAnchor.constraint(equalTo: view.centerXAnchor), + bankIconView.centerYAnchor.constraint(equalTo: view.centerYAnchor), + + cardBrandView.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor), + cardBrandView.topAnchor.constraint(greaterThanOrEqualTo: view.topAnchor), + cardBrandView.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor), + cardBrandView.bottomAnchor.constraint(lessThanOrEqualTo: view.bottomAnchor), + cardBrandView.centerXAnchor.constraint(equalTo: view.centerXAnchor), + cardBrandView.centerYAnchor.constraint(equalTo: view.centerYAnchor), + + cardBrandView.widthAnchor.constraint(equalToConstant: cardBrandSize.width), + cardBrandView.heightAnchor.constraint(equalToConstant: cardBrandSize.height), + ]) + + return view + }() + + private lazy var stackView: UIStackView = { + let labelStackView = UIStackView(arrangedSubviews: [ + primaryLabel, + secondaryLabel, + ]) + labelStackView.axis = .vertical + labelStackView.alignment = .leading + labelStackView.spacing = 0 + + let stackView = UIStackView(arrangedSubviews: [ + iconContainerView, + labelStackView, + ]) + + stackView.spacing = Constants.contentSpacing + stackView.alignment = .center + stackView.translatesAutoresizingMaskIntoConstraints = false + return stackView + }() + + override init(frame: CGRect) { + super.init(frame: frame) + addAndPinSubview(stackView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + } + +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/PaymentMethodPicker/LinkPaymentMethodPicker-Header.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/PaymentMethodPicker/LinkPaymentMethodPicker-Header.swift new file mode 100644 index 00000000000..3064dd16451 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/PaymentMethodPicker/LinkPaymentMethodPicker-Header.swift @@ -0,0 +1,194 @@ +// +// LinkPaymentMethodPicker-Header.swift +// StripePaymentSheet +// +// Created by Ramon Torres on 10/22/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeUICore +import UIKit + +extension LinkPaymentMethodPicker { + + final class Header: UIControl { + struct Constants { + static let contentSpacing: CGFloat = 16 + static let chevronSize: CGSize = .init(width: 24, height: 24) + static let collapsedInsets = NSDirectionalEdgeInsets(top: 20, leading: 20, bottom: 20, trailing: 20) + static let expandedInsets = NSDirectionalEdgeInsets(top: 20, leading: 20, bottom: 4, trailing: 20) + } + + /// The selected payment method. + var selectedPaymentMethod: ConsumerPaymentDetails? { + didSet { + contentView.paymentMethod = selectedPaymentMethod + updateAccessibilityContent() + } + } + + var isExpanded: Bool = false { + didSet { + setNeedsLayout() + updateChevron() + updateAccessibilityContent() + } + } + + override var isHighlighted: Bool { + didSet { + if isHighlighted && !isExpanded { + backgroundColor = .linkControlHighlight + } else { + backgroundColor = .clear + } + } + } + + private let payWithLabel: UILabel = { + let label = UILabel() + label.font = LinkUI.font(forTextStyle: .body) + label.textColor = .linkSecondaryText + label.text = STPLocalizedString("Payment", "Label for a section displaying payment details.") + label.adjustsFontForContentSizeCategory = true + return label + }() + + private let headingLabel: UILabel = { + let label = UILabel() + label.font = LinkUI.font(forTextStyle: .bodyEmphasized) + label.textColor = .linkPrimaryText + label.text = STPLocalizedString( + "Payment methods", + "Title for a section listing one or more payment methods." + ) + label.adjustsFontForContentSizeCategory = true + return label + }() + + private let contentView = CellContentView() + + private let cardNumberLabel: UILabel = { + let label = UILabel() + label.font = UIFont.monospacedDigitSystemFont(ofSize: 14, weight: .medium) + .scaled(withTextStyle: .body, maximumPointSize: 20) + return label + }() + + private lazy var chevron: UIImageView = { + let chevron = UIImageView(image: StripeUICore.Image.icon_chevron_down.makeImage(template: true)) + chevron.contentMode = .center + + NSLayoutConstraint.activate([ + chevron.widthAnchor.constraint(equalToConstant: Constants.chevronSize.width), + chevron.heightAnchor.constraint(equalToConstant: Constants.chevronSize.height), + ]) + + return chevron + }() + + private lazy var paymentInfoStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [ + payWithLabel, + contentView, + ]) + + stackView.alignment = .center + stackView.setCustomSpacing(Constants.contentSpacing, after: payWithLabel) + stackView.translatesAutoresizingMaskIntoConstraints = false + + return stackView + }() + + private lazy var stackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [ + paymentInfoStackView, + headingLabel, + chevron, + ]) + + stackView.axis = .horizontal + stackView.spacing = Constants.contentSpacing + stackView.distribution = .equalSpacing + stackView.alignment = .center + stackView.isLayoutMarginsRelativeArrangement = true + stackView.translatesAutoresizingMaskIntoConstraints = false + + return stackView + }() + + override init(frame: CGRect) { + super.init(frame: .zero) + + addSubview(stackView) + + NSLayoutConstraint.activate([ + // Stack view + stackView.topAnchor.constraint(equalTo: topAnchor), + stackView.bottomAnchor.constraint(equalTo: bottomAnchor), + stackView.leadingAnchor.constraint(equalTo: leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + + isAccessibilityElement = true + accessibilityTraits = .button + + updateChevron() + updateAccessibilityContent() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func updateChevron() { + if isExpanded { + chevron.transform = CGAffineTransform(rotationAngle: .pi) + chevron.tintColor = .linkPrimaryText + } else { + chevron.transform = .identity + chevron.tintColor = .linkSecondaryText + } + } + + private func updateAccessibilityContent() { + if isExpanded { + accessibilityLabel = headingLabel.text + accessibilityHint = STPLocalizedString( + "Tap to close", + "Accessibility hint to tell the user that they can tap to hide additional content." + ) + } else { + accessibilityLabel = selectedPaymentMethod?.accessibilityDescription + accessibilityHint = STPLocalizedString( + "Tap to expand", + "Accessibility hint to tell the user that they can tap to reveal additional content." + ) + } + } + + override func layoutSubviews() { + super.layoutSubviews() + + if isExpanded { + paymentInfoStackView.isHidden = true + headingLabel.isHidden = false + stackView.directionalLayoutMargins = Constants.expandedInsets + } else { + paymentInfoStackView.isHidden = false + headingLabel.isHidden = true + stackView.directionalLayoutMargins = Constants.collapsedInsets + } + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + if self.point(inside: point, with: event) { + return self + } + + return nil + } + + } + +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/PaymentMethodPicker/LinkPaymentMethodPicker-RadioButton.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/PaymentMethodPicker/LinkPaymentMethodPicker-RadioButton.swift new file mode 100644 index 00000000000..b2078ae3046 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/PaymentMethodPicker/LinkPaymentMethodPicker-RadioButton.swift @@ -0,0 +1,116 @@ +// +// LinkPaymentMethodPicker-RadioButton.swift +// StripePaymentSheet +// +// Created by Ramon Torres on 11/15/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import UIKit + +extension LinkPaymentMethodPicker { + + final class RadioButton: UIView { + struct Constants { + static let diameter: CGFloat = 20 + static let innerDiameter: CGFloat = 8 + static let borderWidth: CGFloat = 1 + } + + public var isOn: Bool = false { + didSet { + update() + } + } + + public var borderColor: UIColor = .linkControlBorder { + didSet { + applyStyling() + } + } + + /// Layer for the "off" state. + private let offLayer: CALayer = { + let layer = CALayer() + layer.bounds = CGRect(x: 0, y: 0, width: Constants.diameter, height: Constants.diameter) + layer.cornerRadius = Constants.diameter / 2 + layer.borderWidth = Constants.borderWidth + layer.anchorPoint = CGPoint(x: 0.5, y: 0.5) + return layer + }() + + /// Layer for the "on" state. + private let onLayer: CALayer = { + let layer = CALayer() + layer.bounds = CGRect(x: 0, y: 0, width: Constants.diameter, height: Constants.diameter) + layer.cornerRadius = Constants.diameter / 2 + layer.anchorPoint = CGPoint(x: 0.5, y: 0.5) + + let innerCircle = CALayer() + innerCircle.backgroundColor = UIColor.white.cgColor + innerCircle.cornerRadius = Constants.innerDiameter / 2 + innerCircle.bounds = CGRect(x: 0, y: 0, width: Constants.innerDiameter, height: Constants.innerDiameter) + innerCircle.anchorPoint = CGPoint(x: 0.5, y: 0.5) + + // Add and center inner circle + layer.addSublayer(innerCircle) + innerCircle.position = CGPoint(x: layer.bounds.midX, y: layer.bounds.midY) + + return layer + }() + + override var intrinsicContentSize: CGSize { + return CGSize(width: Constants.diameter, height: Constants.diameter) + } + + init() { + super.init(frame: .zero) + layer.addSublayer(offLayer) + layer.addSublayer(onLayer) + update() + applyStyling() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + applyStyling() + } + + override func layoutSubviews() { + offLayer.position = CGPoint(x: bounds.midX, y: bounds.midY) + onLayer.position = CGPoint(x: bounds.midX, y: bounds.midY) + } + + override func tintColorDidChange() { + super.tintColorDidChange() + applyStyling() + } + +#if !os(visionOS) + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + applyStyling() + } +#endif + + // MARK: - Private methods + + private func update() { + CATransaction.begin() + CATransaction.setDisableActions(true) + offLayer.isHidden = isOn + onLayer.isHidden = !isOn + CATransaction.commit() + } + + private func applyStyling() { + CATransaction.begin() + CATransaction.setDisableActions(true) + offLayer.borderColor = borderColor.cgColor + onLayer.backgroundColor = tintColor.cgColor + CATransaction.commit() + } + + } + +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/PaymentMethodPicker/LinkPaymentMethodPicker.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/PaymentMethodPicker/LinkPaymentMethodPicker.swift new file mode 100644 index 00000000000..805777205e4 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/PaymentMethodPicker/LinkPaymentMethodPicker.swift @@ -0,0 +1,320 @@ +// +// LinkPaymentMethodPicker.swift +// StripePaymentSheet +// +// Created by Ramon Torres on 10/25/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import UIKit + +@_spi(STP) import StripeUICore + +protocol LinkPaymentMethodPickerDelegate: AnyObject { + func paymentMethodPickerDidChange(_ picker: LinkPaymentMethodPicker) + + func paymentMethodPicker( + _ picker: LinkPaymentMethodPicker, + showMenuForItemAt index: Int, + sourceRect: CGRect + ) + + func paymentDetailsPickerDidTapOnAddPayment(_ picker: LinkPaymentMethodPicker) +} + +protocol LinkPaymentMethodPickerDataSource: AnyObject { + + /// Returns the total number of payment methods. + /// - Returns: Payment method count + func numberOfPaymentMethods(in picker: LinkPaymentMethodPicker) -> Int + + /// Returns the payment method at the specific index. + /// - Returns: Payment method. + func paymentPicker( + _ picker: LinkPaymentMethodPicker, + paymentMethodAt index: Int + ) -> ConsumerPaymentDetails + +} + +/// For internal SDK use only +@objc(STP_Internal_LinkPaymentMethodPicker) +final class LinkPaymentMethodPicker: UIView { + weak var delegate: LinkPaymentMethodPickerDelegate? + weak var dataSource: LinkPaymentMethodPickerDataSource? + + var selectedIndex: Int = 0 { + didSet { + updateHeaderView() + } + } + + var supportedPaymentMethodTypes = Set(ConsumerPaymentDetails.DetailsType.allCases) + + var selectedPaymentMethod: ConsumerPaymentDetails? { + let count = dataSource?.numberOfPaymentMethods(in: self) ?? 0 + + guard selectedIndex >= 0 && selectedIndex < count else { + return nil + } + + return dataSource?.paymentPicker(self, paymentMethodAt: selectedIndex) + } + + private var needsDataReload: Bool = true + + private lazy var stackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [ + headerView, + listView, + ]) + + stackView.axis = .vertical + stackView.clipsToBounds = true + stackView.translatesAutoresizingMaskIntoConstraints = false + return stackView + }() + + private let headerView = Header() + + private lazy var listView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [ + addPaymentMethodButton + ]) + + stackView.axis = .vertical + stackView.distribution = .equalSpacing + stackView.clipsToBounds = true + stackView.translatesAutoresizingMaskIntoConstraints = false + return stackView + }() + + #if !os(visionOS) + private let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .medium) + private let selectionFeedbackGenerator = UISelectionFeedbackGenerator() + #endif + + private let addPaymentMethodButton = AddButton() + + override init(frame: CGRect) { + super.init(frame: .zero) + addAndPinSubview(stackView) + setup() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setup() { + clipsToBounds = true + accessibilityIdentifier = "Stripe.Link.PaymentMethodPicker" + + layer.cornerRadius = 16 + layer.borderWidth = 1 + layer.borderColor = UIColor.linkControlBorder.cgColor + tintColor = .linkBrandDark + backgroundColor = .linkControlBackground + + headerView.addTarget(self, action: #selector(onHeaderTapped(_:)), for: .touchUpInside) + headerView.layer.zPosition = 1 + + listView.isHidden = true + listView.layer.zPosition = 0 + + addPaymentMethodButton.addTarget(self, action: #selector(onAddPaymentButtonTapped(_:)), for: .touchUpInside) + } + + override func layoutSubviews() { + super.layoutSubviews() + reloadDataIfNeeded() + } + +#if !os(visionOS) + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + layer.borderColor = UIColor.linkControlBorder.cgColor + } +#endif + + func setExpanded(_ expanded: Bool, animated: Bool) { + headerView.isExpanded = expanded + + // Prevent double header animation + if headerView.isExpanded { + // TODO(ramont): revise layout margin placement and remove conditional + setNeedsLayout() + layoutIfNeeded() + } else { + headerView.layoutIfNeeded() + } + + if headerView.isExpanded { + stackView.showArrangedSubview(at: 1, animated: animated) + } else { + stackView.hideArrangedSubview(at: 1, animated: animated) + } + } + + private func updateHeaderView() { + headerView.selectedPaymentMethod = selectedPaymentMethod + } + +} + +private extension LinkPaymentMethodPicker { + + @objc func onHeaderTapped(_ sender: Header) { + setExpanded(!sender.isExpanded, animated: true) +#if !os(visionOS) + impactFeedbackGenerator.impactOccurred() +#endif + } + + @objc func onAddPaymentButtonTapped(_ sender: AddButton) { + delegate?.paymentDetailsPickerDidTapOnAddPayment(self) + } + +} + +// MARK: - Data Loading + +extension LinkPaymentMethodPicker { + + func reloadData() { + needsDataReload = false + + addMissingPaymentMethodCells() + + let count = dataSource?.numberOfPaymentMethods(in: self) ?? 0 + if count == 0 { + headerView.isHidden = true + listView.isHidden = false + } + + for index in 0.. listView.arrangedSubviews.count - 1 { + let cell = Cell() + cell.delegate = self + + let index = listView.arrangedSubviews.count - 1 + listView.insertArrangedSubview(cell, at: index) + } + + for (index, subview) in listView.arrangedSubviews.enumerated() { + subview.layer.zPosition = CGFloat(-index) + } + + headerView.selectedPaymentMethod = selectedPaymentMethod + } + +} + +extension LinkPaymentMethodPicker { + + func index(for cell: Cell) -> Int? { + return listView.arrangedSubviews.firstIndex(of: cell) + } + + func removePaymentMethod(at index: Int, animated: Bool) { + isUserInteractionEnabled = false + + listView.removeArrangedSubview(at: index, animated: true) { + let count = self.dataSource?.numberOfPaymentMethods(in: self) ?? 0 + + if index < self.selectedIndex { + self.selectedIndex = max(self.selectedIndex - 1, 0) + } + + self.selectedIndex = max(min(self.selectedIndex, count - 1), 0) + + self.reloadData() + self.delegate?.paymentMethodPickerDidChange(self) + self.isUserInteractionEnabled = true + } + } + +} + +// MARK: - Cell delegate + +extension LinkPaymentMethodPicker: LinkPaymentMethodPickerCellDelegate { + + func savedPaymentPickerCellDidSelect(_ savedCardView: Cell) { + if let newIndex = index(for: savedCardView) { + let oldIndex = selectedIndex + selectedIndex = newIndex + + reloadCell(at: oldIndex) + reloadCell(at: newIndex) + +#if !os(visionOS) + selectionFeedbackGenerator.selectionChanged() +#endif + + delegate?.paymentMethodPickerDidChange(self) + } + } + + func savedPaymentPickerCell(_ cell: Cell, didTapMenuButton button: UIButton) { + guard let index = index(for: cell) else { + assertionFailure("Index not found") + return + } + + let sourceRect = button.convert(button.bounds, to: self) + + delegate?.paymentMethodPicker(self, showMenuForItemAt: index, sourceRect: sourceRect) + } + +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Elements/LinkCardEditElement.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Elements/LinkCardEditElement.swift new file mode 100644 index 00000000000..7e75b07f195 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Elements/LinkCardEditElement.swift @@ -0,0 +1,225 @@ +// +// LinkCardEditElement.swift +// StripePaymentSheet +// +// Created by Ramon Torres on 9/30/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeCore +@_spi(STP) import StripePayments +@_spi(STP) import StripePaymentsUI +@_spi(STP) import StripeUICore +import UIKit + +// TODO(ramont): Remove after migrating to modern bindings +fileprivate extension ConsumerPaymentDetails { + var cardDetails: Details.Card? { + switch details { + case .card(let details): + return details + case .bankAccount: + return nil + case .unparsable: + return nil + } + } +} + +final class LinkCardEditElement: Element { + let collectsUserInput: Bool = true + + struct Params { + let expiryDate: CardExpiryDate + let cvc: String + let billingDetails: STPPaymentMethodBillingDetails + let setAsDefault: Bool + } + + var view: UIView { + return formElement.view + } + + weak var delegate: ElementDelegate? + + var validationState: ElementValidationState { + return formElement.validationState + } + + let paymentMethod: ConsumerPaymentDetails + + let configuration: PaymentSheet.Configuration + + let theme: ElementsAppearance = LinkUI.appearance.asElementsTheme + + var params: Params? { + guard validationState.isValid, + let expiryDate = CardExpiryDate(expiryDateElement.text) else { + return nil + } + + // TODO(ramont): Replace `STPPaymentMethodBillingDetails` with a custom struct for Link. + // This matches the object that was returned by CardDetailsEditView, but won't work + // with `collectionMode: .all`, because extra fields won't match what expected by Link. + let billingDetails = STPPaymentMethodBillingDetails() + billingDetails.name = nameElement?.text + billingDetails.email = emailElement?.text + billingDetails.nonnil_address.country = billingAddressSection?.selectedCountryCode + billingDetails.nonnil_address.line1 = billingAddressSection?.line1?.text + billingDetails.nonnil_address.line2 = billingAddressSection?.line2?.text + billingDetails.nonnil_address.city = billingAddressSection?.city?.text + billingDetails.nonnil_address.state = billingAddressSection?.state?.rawData + billingDetails.nonnil_address.postalCode = billingAddressSection?.postalCode?.text + + return Params( + expiryDate: expiryDate, + cvc: cvcElement.text, + billingDetails: billingDetails, + setAsDefault: checkboxElement.checkboxButton.isSelected + ) + } + + private lazy var emailElement: TextFieldElement? = { + guard configuration.billingDetailsCollectionConfiguration.email == .always else { return nil } + + return TextFieldElement.makeEmail(defaultValue: nil, theme: theme) + }() + + private lazy var contactInformationSection: SectionElement? = { + guard let emailElement = emailElement else { return nil } + + return SectionElement( + title: STPLocalizedString("Contact information", "Title for the contact information section"), + elements: [emailElement], + theme: theme) + }() + + private lazy var nameElement: TextFieldElement? = { + guard configuration.billingDetailsCollectionConfiguration.name == .always else { return nil } + + return TextFieldElement.makeName( + label: STPLocalizedString("Name on card", "Label for name on card field"), + defaultValue: nil, + theme: theme) + }() + + private lazy var panElement: TextFieldElement = { + let panElement = TextFieldElement( + configuration: PANConfiguration(paymentMethod: paymentMethod), + theme: theme + ) + panElement.view.isUserInteractionEnabled = false + return panElement + }() + + private lazy var cvcElement = TextFieldElement( + configuration: TextFieldElement.CVCConfiguration( + cardBrandProvider: { [weak self] in + self?.paymentMethod.cardDetails?.stpBrand ?? .unknown + } + ), + theme: theme + ) + + private lazy var expiryDateElement = TextFieldElement( + configuration: TextFieldElement.ExpiryDateConfiguration(), + theme: theme + ) + + private lazy var checkboxElement = CheckboxElement( + theme: theme, + label: STPLocalizedString( + "Set as default payment method", + "Label of a checkbox that when checked makes a payment method as the default one." + ), + isSelectedByDefault: paymentMethod.isDefault + ) + + private lazy var formElement: FormElement = { + let formElement = FormElement( + elements: [ + contactInformationSection, + cardSection, + billingAddressSection, + checkboxElement, + ], + theme: theme + ) + formElement.delegate = self + return formElement + }() + + private lazy var cardSection: SectionElement = { + let allElements: [Element?] = [ + nameElement, + panElement, + SectionElement.MultiElementRow([expiryDateElement, cvcElement], theme: theme), + ] + let elements = allElements.compactMap { $0 } + + return SectionElement( + title: String.Localized.card_information, + elements: elements, + theme: theme + ) + }() + + private lazy var billingAddressSection: AddressSectionElement? = { + guard configuration.billingDetailsCollectionConfiguration.address != .never else { return nil } + + return AddressSectionElement( + title: String.Localized.billing_address_lowercase, + collectionMode: configuration.billingDetailsCollectionConfiguration.address == .full + ? .all() + : .countryAndPostal(), + theme: theme + ) + }() + + init(paymentMethod: ConsumerPaymentDetails, configuration: PaymentSheet.Configuration) { + self.paymentMethod = paymentMethod + self.configuration = configuration + + if let expiryDate = paymentMethod.cardDetails?.expiryDate { + self.expiryDateElement.setText(expiryDate.displayString) + } + + self.checkboxElement.checkboxButton.isHidden = paymentMethod.isDefault + } + +} + +extension LinkCardEditElement: ElementDelegate { + + func didUpdate(element: Element) { + delegate?.didUpdate(element: self) + } + + func continueToNextField(element: Element) { + delegate?.continueToNextField(element: self) + } + +} + +private extension LinkCardEditElement { + + struct PANConfiguration: TextFieldElementConfiguration { + let paymentMethod: ConsumerPaymentDetails + + var label: String { + String.Localized.card_number + } + + var defaultValue: String? { + paymentMethod.cardDetails.map { "•••• \($0.last4)" } + } + + func accessoryView(for text: String, theme: ElementsAppearance) -> UIView? { + paymentMethod.cardDetails.map { cardDetails in + let image = STPImageLibrary.cardBrandImage(for: cardDetails.stpBrand) + return UIImageView(image: image) + } + } + } + +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Verification/LinkVerificationController.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Verification/LinkVerificationController.swift new file mode 100644 index 00000000000..8de873a7703 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Verification/LinkVerificationController.swift @@ -0,0 +1,49 @@ +// +// LinkVerificationController.swift +// StripePaymentSheet +// +// Created by Ramon Torres on 7/23/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import UIKit + +/// Standalone verification controller. +final class LinkVerificationController { + + typealias CompletionBlock = (LinkVerificationViewController.VerificationResult) -> Void + + private var completion: CompletionBlock? + + private var selfRetainer: LinkVerificationController? + private let verificationViewController: LinkVerificationViewController + + init(mode: LinkVerificationView.Mode = .modal, linkAccount: PaymentSheetLinkAccount) { + self.verificationViewController = LinkVerificationViewController(mode: mode, linkAccount: linkAccount) + verificationViewController.delegate = self + } + + func present( + from presentingController: UIViewController, + completion: @escaping CompletionBlock + ) { + self.selfRetainer = self + self.completion = completion + presentingController.present(verificationViewController, animated: true) + } + +} + +extension LinkVerificationController: LinkVerificationViewControllerDelegate { + + func verificationController( + _ controller: LinkVerificationViewController, + didFinishWithResult result: LinkVerificationViewController.VerificationResult + ) { + controller.dismiss(animated: true) { [weak self] in + self?.completion?(result) + self?.selfRetainer = nil + } + } + +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Verification/LinkVerificationView-Header.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Verification/LinkVerificationView-Header.swift new file mode 100644 index 00000000000..4005ed91de8 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Verification/LinkVerificationView-Header.swift @@ -0,0 +1,69 @@ +// +// LinkVerificationView-Header.swift +// StripePaymentSheet +// +// Created by Ramon Torres on 12/1/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeCore +@_spi(STP) import StripePayments +@_spi(STP) import StripeUICore +import UIKit + +extension LinkVerificationView { + + final class Header: UIView { + struct Constants { + static let logoHeight: CGFloat = 24 + } + + private let logoView: UIImageView = { + let logoView = UIImageView(image: Image.link_logo.makeImage(template: false)) + logoView.translatesAutoresizingMaskIntoConstraints = false + logoView.isAccessibilityElement = true + logoView.accessibilityTraits = .header + logoView.accessibilityLabel = STPPaymentMethodType.link.displayName + return logoView + }() + + let closeButton: UIButton = { + let button = UIButton(type: .system) + button.setImage(Image.icon_cancel.makeImage(template: true), for: .normal) + button.translatesAutoresizingMaskIntoConstraints = false + button.accessibilityLabel = String.Localized.close + return button + }() + + override var intrinsicContentSize: CGSize { + return CGSize(width: UIView.noIntrinsicMetric, height: 24) + } + + init() { + super.init(frame: .zero) + + addSubview(logoView) + addSubview(closeButton) + + NSLayoutConstraint.activate([ + // Logo + logoView.centerXAnchor.constraint(equalTo: centerXAnchor), + logoView.centerYAnchor.constraint(equalTo: centerYAnchor), + logoView.heightAnchor.constraint(equalToConstant: Constants.logoHeight), + + // Button + closeButton.topAnchor.constraint(equalTo: topAnchor), + closeButton.rightAnchor.constraint(equalTo: rightAnchor), + closeButton.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + tintColor = .linkNavTint + logoView.tintColor = .linkNavLogo + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + } + +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Verification/LinkVerificationView-LogoutView.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Verification/LinkVerificationView-LogoutView.swift new file mode 100644 index 00000000000..0bafc76033c --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Verification/LinkVerificationView-LogoutView.swift @@ -0,0 +1,65 @@ +// +// LinkVerificationView-LogoutView.swift +// StripePaymentSheet +// +// Created by Ramon Torres on 2/4/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore +import UIKit + +extension LinkVerificationView { + + final class LogoutView: UIView { + let linkAccount: PaymentSheetLinkAccountInfoProtocol + + private let font: UIFont = LinkUI.font(forTextStyle: .detail) + + private lazy var label: UILabel = { + let label = UILabel() + label.font = font + label.adjustsFontForContentSizeCategory = true + label.textColor = .linkSecondaryText + label.lineBreakMode = .byTruncatingMiddle + label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + label.text = String( + format: STPLocalizedString( + "Not %@?", + "Text of a label for confirming an email address. E.g., 'Not user@example.com?'" + ), + linkAccount.email + ) + return label + }() + + private(set) lazy var button: Button = { + let button = Button(configuration: .linkPlain(), title: STPLocalizedString( + "Change email", + "Title for a button that allows the user to use a different email in the signup flow." + )) + button.configuration.font = font + return button + }() + + init(linkAccount: PaymentSheetLinkAccountInfoProtocol) { + self.linkAccount = linkAccount + super.init(frame: .zero) + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupUI() { + let stackView = UIStackView(arrangedSubviews: [label, button]) + stackView.spacing = LinkUI.tinyContentSpacing + stackView.alignment = .center + stackView.distribution = .equalSpacing + addAndPinSubview(stackView) + } + } + +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Verification/LinkVerificationView.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Verification/LinkVerificationView.swift new file mode 100644 index 00000000000..77ff077d193 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Verification/LinkVerificationView.swift @@ -0,0 +1,271 @@ +// +// LinkVerificationView.swift +// StripePaymentSheet +// +// Created by Cameron Sabol on 3/24/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import UIKit + +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore + +/// :nodoc: +protocol LinkVerificationViewDelegate: AnyObject { + func verificationViewDidCancel(_ view: LinkVerificationView) + func verificationViewResendCode(_ view: LinkVerificationView) + func verificationViewLogout(_ view: LinkVerificationView) + func verificationView(_ view: LinkVerificationView, didEnterCode code: String) +} + +/// For internal SDK use only +@objc(STP_Internal_LinkVerificationView) +final class LinkVerificationView: UIView { + struct Constants { + static let edgeMargin: CGFloat = 20 + } + + enum Mode { + case modal + case inlineLogin + case embedded + } + + weak var delegate: LinkVerificationViewDelegate? + + private let mode: Mode + + let linkAccount: PaymentSheetLinkAccountInfoProtocol + + var sendingCode: Bool = false { + didSet { + resendCodeButton.isLoading = sendingCode + } + } + + var errorMessage: String? { + didSet { + errorLabel.text = errorMessage + errorLabel.setHiddenIfNecessary(errorMessage == nil) + } + } + + private lazy var header: Header = { + let header = Header() + header.closeButton.addTarget(self, action: #selector(didSelectCancel), for: .touchUpInside) + return header + }() + + private lazy var headingLabel: UILabel = { + let label = UILabel() + label.numberOfLines = 0 + label.textAlignment = .center + label.font = LinkUI.font(forTextStyle: .title) + label.textColor = .linkPrimaryText + label.text = mode.headingText + label.adjustsFontForContentSizeCategory = true + return label + }() + + private lazy var bodyLabel: UILabel = { + let label = UILabel() + label.numberOfLines = 0 + label.textAlignment = .center + label.font = mode.bodyFont + label.textColor = .linkSecondaryText + label.text = mode.bodyText(redactedPhoneNumber: linkAccount.redactedPhoneNumber ?? "") + label.adjustsFontForContentSizeCategory = true + return label + }() + + private(set) lazy var codeField: OneTimeCodeTextField = { + let codeField = OneTimeCodeTextField(configuration: + .init(numberOfDigits: 6), + theme: LinkUI.appearance.asElementsTheme) + codeField.addTarget(self, action: #selector(oneTimeCodeFieldChanged(_:)), for: .valueChanged) + return codeField + }() + + private let errorLabel: UILabel = { + let label = UILabel() + label.font = LinkUI.font(forTextStyle: .detail) + label.textColor = .systemRed + label.numberOfLines = 0 + label.textAlignment = .center + label.isHidden = true + return label + }() + + private lazy var codeFieldContainer: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [codeField, errorLabel]) + stackView.axis = .vertical + stackView.spacing = LinkUI.smallContentSpacing + return stackView + }() + + private lazy var resendCodeButton: Button = { + let button = Button(configuration: .linkBordered(), title: STPLocalizedString( + "Resend code", + "Label for a button that re-sends the a login code when tapped" + )) + button.addTarget(self, action: #selector(resendCodeTapped(_:)), for: .touchUpInside) + return button + }() + + private lazy var logoutView: LogoutView = { + let logoutView = LogoutView(linkAccount: linkAccount) + logoutView.button.addTarget(self, action: #selector(didTapOnLogout(_:)), for: .touchUpInside) + return logoutView + }() + + required init(mode: Mode, linkAccount: PaymentSheetLinkAccountInfoProtocol) { + self.mode = mode + self.linkAccount = linkAccount + super.init(frame: .zero) + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc + func oneTimeCodeFieldChanged(_ sender: OneTimeCodeTextField) { + // Clear error message when the field changes. + errorMessage = nil + + if sender.isComplete { + delegate?.verificationView(self, didEnterCode: sender.value) + } + } + + @objc + func didSelectCancel() { + delegate?.verificationViewDidCancel(self) + } + + @objc + func didTapOnLogout(_ sender: UIButton) { + delegate?.verificationViewLogout(self) + } + + @objc + func resendCodeTapped(_ sender: UIButton) { + delegate?.verificationViewResendCode(self) + } +} + +private extension LinkVerificationView { + + var arrangedSubViews: [UIView] { + switch mode { + case .modal, .inlineLogin: + return [ + header, + headingLabel, + bodyLabel, + codeFieldContainer, + resendCodeButton, + ] + case .embedded: + return [ + headingLabel, + bodyLabel, + codeFieldContainer, + logoutView, + resendCodeButton, + ] + } + } + + func setupUI() { + directionalLayoutMargins = .insets(amount: Constants.edgeMargin) + + let stackView = UIStackView(arrangedSubviews: arrangedSubViews) + stackView.spacing = LinkUI.smallContentSpacing + stackView.axis = .vertical + stackView.alignment = .center + stackView.translatesAutoresizingMaskIntoConstraints = false + + // Spacing + stackView.setCustomSpacing(Constants.edgeMargin, after: header) + stackView.setCustomSpacing(LinkUI.extraLargeContentSpacing, after: bodyLabel) + stackView.setCustomSpacing(LinkUI.largeContentSpacing, after: codeFieldContainer) + + addSubview(stackView) + + var constraints: [NSLayoutConstraint] = [ + // Stack view + stackView.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor), + stackView.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor), + stackView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), + + // OTC field + codeFieldContainer.leadingAnchor.constraint(equalTo: stackView.leadingAnchor), + codeFieldContainer.trailingAnchor.constraint(equalTo: stackView.trailingAnchor), + ] + + if mode.requiresModalPresentation { + constraints.append(contentsOf: [ + // Header + header.leadingAnchor.constraint(equalTo: stackView.leadingAnchor), + header.trailingAnchor.constraint(equalTo: stackView.trailingAnchor), + ]) + } + + NSLayoutConstraint.activate(constraints) + backgroundColor = .systemBackground + } +} + +extension LinkVerificationView.Mode { + + var requiresModalPresentation: Bool { + switch self { + case .modal, .inlineLogin: + return true + case .embedded: + return false + } + } + + var headingText: String { + switch self { + case .modal: + return STPLocalizedString( + "Use your saved info to check out faster", + "Two factor authentication screen heading" + ) + case .inlineLogin: + return STPLocalizedString( + "Sign in to your Link account", + "Two factor authentication screen heading" + ) + case .embedded: + return STPLocalizedString( + "Enter your verification code", + "Two factor authentication screen heading" + ) + } + } + + var bodyFont: UIFont { + switch self { + case .modal, .inlineLogin: + return LinkUI.font(forTextStyle: .detail) + case .embedded: + return LinkUI.font(forTextStyle: .body) + } + } + + func bodyText(redactedPhoneNumber: String) -> String { + let format = STPLocalizedString( + "Enter the code sent to %@ to use Link to pay by default.", + "Instructs the user to enter the code sent to their phone number in order to login to Link" + ) + return String(format: format, redactedPhoneNumber) + } + +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Verification/LinkVerificationViewController-PresentationController.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Verification/LinkVerificationViewController-PresentationController.swift new file mode 100644 index 00000000000..fbd2417c131 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Verification/LinkVerificationViewController-PresentationController.swift @@ -0,0 +1,211 @@ +// +// LinkVerificationViewController-PresentationController.swift +// StripePaymentSheet +// +// Created by Ramon Torres on 11/7/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeUICore +import UIKit + +extension LinkVerificationViewController { + + /// For internal SDK use only + @objc(STP_Internal_LinkPresentationController) + final class PresentationController: UIPresentationController { + struct Constants { + static let padding: CGFloat = 16 + static let maxWidth: CGFloat = 400 + static let maxHeight: CGFloat = 410 + static let targetHeight: CGFloat = 332 + } + + /// A bottom inset necessary for the presented view to avoid the software keyboard. + private var bottomInset: CGFloat = 0 + + /// An area where it is safe to present the modal on. + /// + /// This is always equals to the container view safe area minus `padding` on eat edge. + private var safeFrame: CGRect { + guard let containerView = containerView else { + return .zero + } + + return containerView.bounds + .inset(by: containerView.safeAreaInsets) + .insetBy(dx: Constants.padding, dy: Constants.padding) + } + + private lazy var dimmingView: UIView = { + let view = UIView() + view.backgroundColor = UIColor.black.withAlphaComponent(0.5) + return view + }() + + private var contentView: UIView? { + if let scrollView = presentedView as? UIScrollView { + return scrollView.subviews.first + } + + return presentedView + } + + override var frameOfPresentedViewInContainerView: CGRect { + guard let containerView = containerView else { + return .zero + } + + return calculateModalFrame(forContainerSize: containerView.bounds.size) + } + + func updatePresentedViewFrame() { + presentedView?.frame = frameOfPresentedViewInContainerView + } + + private func calculateModalFrame(forContainerSize containerSize: CGSize) -> CGRect { + guard let contentView = contentView else { + return .zero + } + + let targetSize = CGSize( + width: min(Constants.maxWidth, safeFrame.width), + height: Constants.targetHeight + ) + + let fittingSize = contentView.systemLayoutSizeFitting( + targetSize, + withHorizontalFittingPriority: .required, + verticalFittingPriority: .defaultLow + ) + + let actualSize = CGSize( + width: fittingSize.width, + height: min(fittingSize.height, Constants.maxHeight) + ) + + return CGRect( + x: (containerSize.width - actualSize.width) / 2, + y: max((containerSize.height - actualSize.height - bottomInset) / 2, Constants.padding), + width: actualSize.width, + height: actualSize.height + ).integral + } + + override func containerViewWillLayoutSubviews() { + super.containerViewWillLayoutSubviews() + + guard let containerView = containerView else { + return + } + + dimmingView.frame = containerView.bounds + presentedView?.frame = frameOfPresentedViewInContainerView + } + + override func presentationTransitionWillBegin() { + super.presentationTransitionWillBegin() + + guard let containerView = containerView, + let transitionCoordinator = presentedViewController.transitionCoordinator else { + return + } + + containerView.insertSubview(dimmingView, at: 0) + dimmingView.frame = containerView.bounds + dimmingView.alpha = 0 + + transitionCoordinator.animate { _ in + self.dimmingView.alpha = 1 + } + } + + override func dismissalTransitionWillBegin() { + super.dismissalTransitionWillBegin() + guard let transitionCoordinator = presentedViewController.transitionCoordinator else { + return + } + + transitionCoordinator.animate( + alongsideTransition: { _ in + self.dimmingView.alpha = 0.0 + }, + completion: { _ in + self.dimmingView.removeFromSuperview() + } + ) + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + + coordinator.animate { context in + self.presentedView?.frame = self.calculateModalFrame( + forContainerSize: context.containerView.bounds.size + ) + } + } + + override func willTransition(to newCollection: UITraitCollection, with coordinator: UIViewControllerTransitionCoordinator) { + super.willTransition(to: newCollection, with: coordinator) + + coordinator.animate { context in + self.presentedView?.frame = self.calculateModalFrame( + forContainerSize: context.containerView.bounds.size + ) + } + } + + override init( + presentedViewController: UIViewController, + presenting presentingViewController: UIViewController? + ) { + super.init(presentedViewController: presentedViewController, presenting: presentingViewController) + + NotificationCenter.default.addObserver(self, + selector: #selector(keyboardFrameChanged(_:)), + name: UIResponder.keyboardWillChangeFrameNotification, + object: nil) + + NotificationCenter.default.addObserver(self, + selector: #selector(keyboardWillHide(_:)), + name: UIResponder.keyboardWillHideNotification, + object: nil) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + } + +} + +// MARK: - Keyboard handling + +extension LinkVerificationViewController.PresentationController { + + @objc func keyboardFrameChanged(_ notification: Notification) { + let userInfo = notification.userInfo + + guard let keyboardFrame = userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect, + let containerView = containerView else { + return + } + + let absoluteFrame = containerView.convert(safeFrame, to: containerView.window) + let intersection = absoluteFrame.intersection(keyboardFrame) + + UIView.animateAlongsideKeyboard(notification) { + self.bottomInset = intersection.height + self.presentedView?.frame = self.frameOfPresentedViewInContainerView + } + } + + @objc func keyboardWillHide(_ notification: Notification) { + UIView.animateAlongsideKeyboard(notification) { + self.bottomInset = 0 + self.presentedView?.frame = self.frameOfPresentedViewInContainerView + } + } + +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Verification/LinkVerificationViewController.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Verification/LinkVerificationViewController.swift new file mode 100644 index 00000000000..5f4f09b5267 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Verification/LinkVerificationViewController.swift @@ -0,0 +1,247 @@ +// +// LinkVerificationViewController.swift +// StripePaymentSheet +// +// Created by Cameron Sabol on 3/24/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import UIKit + +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore + +protocol LinkVerificationViewControllerDelegate: AnyObject { + func verificationController( + _ controller: LinkVerificationViewController, + didFinishWithResult result: LinkVerificationViewController.VerificationResult + ) +} + +/// For internal SDK use only +@objc(STP_Internal_LinkVerificationViewController) +final class LinkVerificationViewController: UIViewController { + enum VerificationResult { + /// Verification was completed successfully. + case completed + /// Verification was canceled by the user. + case canceled + /// Verification failed due to an unrecoverable error. + case failed(Error) + } + + weak var delegate: LinkVerificationViewControllerDelegate? + + let mode: LinkVerificationView.Mode + let linkAccount: PaymentSheetLinkAccount + + private lazy var verificationView: LinkVerificationView = { + guard linkAccount.redactedPhoneNumber != nil else { + preconditionFailure("Verification(2FA) presented without a phone number on file") + } + + let verificationView = LinkVerificationView(mode: mode, linkAccount: linkAccount) + verificationView.delegate = self + verificationView.backgroundColor = .clear + verificationView.translatesAutoresizingMaskIntoConstraints = false + + return verificationView + }() + + private lazy var activityIndicator: ActivityIndicator = { + let activityIndicator = ActivityIndicator(size: .large) + activityIndicator.translatesAutoresizingMaskIntoConstraints = false + return activityIndicator + }() + + private lazy var scrollView = LinkKeyboardAvoidingScrollView() + + required init( + mode: LinkVerificationView.Mode = .modal, + linkAccount: PaymentSheetLinkAccount + ) { + self.mode = mode + self.linkAccount = linkAccount + super.init(nibName: nil, bundle: nil) + + if mode.requiresModalPresentation { + modalPresentationStyle = .custom + transitioningDelegate = TransitioningDelegate.sharedDelegate + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + self.view = scrollView + } + + override func viewDidLoad() { + super.viewDidLoad() + + view.tintColor = .linkBrand + view.backgroundColor = .systemBackground + + view.addSubview(verificationView) + view.addSubview(activityIndicator) + + NSLayoutConstraint.activate([ + // Verification view + verificationView.topAnchor.constraint(equalTo: view.topAnchor), + verificationView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + verificationView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + verificationView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + verificationView.widthAnchor.constraint(equalTo: view.widthAnchor), + // Activity indicator + activityIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor), + activityIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + + if mode.requiresModalPresentation { + view.layer.masksToBounds = true + view.layer.cornerRadius = LinkUI.cornerRadius + } + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + if let presentationController = presentationController as? PresentationController { + presentationController.updatePresentedViewFrame() + } + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + activityIndicator.startAnimating() + + if linkAccount.sessionState == .requiresVerification { + verificationView.isHidden = true + + linkAccount.startVerification { [weak self] result in + switch result { + case .success(let collectOTP): + if collectOTP { + self?.activityIndicator.stopAnimating() + self?.verificationView.isHidden = false + self?.verificationView.codeField.becomeFirstResponder() + } else { + // No OTP collection is required. + self?.finish(withResult: .completed) + } + case .failure(let error): + STPAnalyticsClient.sharedClient.logLink2FAStartFailure() + self?.finish(withResult: .failed(error)) + } + } + } else { + verificationView.codeField.becomeFirstResponder() + } + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + STPAnalyticsClient.sharedClient.logLink2FAStart() + } + +} + +/// :nodoc: +extension LinkVerificationViewController: LinkVerificationViewDelegate { + + func verificationViewDidCancel(_ view: LinkVerificationView) { + // Mark email as logged out to prevent automatically showing + // the 2FA modal in future checkout sessions. + linkAccount.markEmailAsLoggedOut() + + STPAnalyticsClient.sharedClient.logLink2FACancel() + finish(withResult: .canceled) + } + + func verificationViewResendCode(_ view: LinkVerificationView) { + view.sendingCode = true + view.errorMessage = nil + + // To resend the code we just start a new verification session. + linkAccount.startVerification { [weak self] (result) in + view.sendingCode = false + + switch result { + case .success: + let toast = LinkToast( + type: .success, + text: STPLocalizedString( + "Code sent", + "Text of a notification shown to the user when a login code is successfully sent via SMS." + ) + ) + toast.show(from: view) + case .failure(let error): + let alertController = UIAlertController( + title: nil, + message: error.localizedDescription, + preferredStyle: .alert + ) + + alertController.addAction(UIAlertAction( + title: String.Localized.ok, + style: .default + )) + + self?.present(alertController, animated: true) + } + } + } + + func verificationViewLogout(_ view: LinkVerificationView) { + STPAnalyticsClient.sharedClient.logLink2FACancel() + finish(withResult: .canceled) + } + + func verificationView(_ view: LinkVerificationView, didEnterCode code: String) { + view.codeField.resignFirstResponder() + + linkAccount.verify(with: code) { [weak self] result in + switch result { + case .success: + self?.finish(withResult: .completed) + STPAnalyticsClient.sharedClient.logLink2FAComplete() + case .failure(let error): + view.codeField.performInvalidCodeAnimation() + view.errorMessage = LinkUtils.getLocalizedErrorMessage(from: error) + STPAnalyticsClient.sharedClient.logLink2FAFailure() + } + } + } + +} + +extension LinkVerificationViewController { + + private func finish(withResult result: VerificationResult) { + // Delete the last "signup email" cookie, if any, after the user completes or declines verification. + LinkAccountService.defaultCookieStore.delete(key: .lastSignupEmail) + delegate?.verificationController(self, didFinishWithResult: result) + } + +} + +// MARK: - Transitioning Delegate + +extension LinkVerificationViewController { + + final class TransitioningDelegate: NSObject, UIViewControllerTransitioningDelegate { + static let sharedDelegate: TransitioningDelegate = TransitioningDelegate() + + func presentationController(forPresented presented: UIViewController, + presenting: UIViewController?, + source: UIViewController) -> UIPresentationController? { + return PresentationController(presentedViewController: presented, + presenting: presenting) + } + } + +} diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkCardEditElementSnapshotTests.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkCardEditElementSnapshotTests.swift new file mode 100644 index 00000000000..ded3d7b0db0 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkCardEditElementSnapshotTests.swift @@ -0,0 +1,73 @@ +// +// LinkCardEditElementSnapshotTests.swift +// StripeiOS Tests +// +// Created by Ramon Torres on 10/3/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import UIKit + +@testable@_spi(STP) import StripeCoreTestUtils +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripeUICore + +final class LinkCardEditElementSnapshotTests: STPSnapshotTestCase { + + override func setUp() { + super.setUp() + // self.recordMode = true + + // `LinkCardEditElement` depends on `AddressSectionElement`, which requires + // address specs to be loaded in memory. + let expectation = expectation(description: "Load address specs") + AddressSpecProvider.shared.loadAddressSpecs { + expectation.fulfill() + } + + wait(for: [expectation], timeout: STPTestingNetworkRequestTimeout) + } + + func testDefault() { + let sut = makeSUT(isDefault: true) + verify(sut) + } + + func testNonDefault() { + let sut = makeSUT(isDefault: false) + verify(sut) + } + + func verify( + _ element: LinkCardEditElement, + identifier: String? = nil, + file: StaticString = #filePath, + line: UInt = #line + ) { + element.view.autosizeHeight(width: 340) + STPSnapshotVerifyView(element.view, identifier: identifier, file: file, line: line) + } + +} + +extension LinkCardEditElementSnapshotTests { + + func makeSUT(isDefault: Bool) -> LinkCardEditElement { + let paymentMethod = ConsumerPaymentDetails( + stripeID: "1", + details: .card( + card: .init( + expiryYear: 2032, + expiryMonth: 1, + brand: "visa", + last4: "4242", + checks: nil + ) + ), + isDefault: isDefault + ) + + return LinkCardEditElement(paymentMethod: paymentMethod, configuration: .init()) + } + +} diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkPaymentMethodPickerSnapshotTests.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkPaymentMethodPickerSnapshotTests.swift new file mode 100644 index 00000000000..240a5ffb90c --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkPaymentMethodPickerSnapshotTests.swift @@ -0,0 +1,101 @@ +// +// LinkPaymentMethodPickerSnapshotTests.swift +// StripeiOS Tests +// +// Created by Ramon Torres on 11/8/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import StripeCoreTestUtils +import UIKit + +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class LinkPaymentMethodPickerSnapshotTests: STPSnapshotTestCase { + + func testNormal() { + let mockDataSource = MockDataSource() + + let picker = LinkPaymentMethodPicker() + picker.dataSource = mockDataSource + picker.layoutSubviews() + + verify(picker, identifier: "First Option") + + picker.selectedIndex = 1 + verify(picker, identifier: "Second Option") + } + + func testExpanded() { + let mockDataSource = MockDataSource() + + let picker = LinkPaymentMethodPicker() + picker.dataSource = mockDataSource + picker.layoutSubviews() + picker.setExpanded(true, animated: false) + + verify(picker) + } + + func testUnsupportedBankAccount() { + let mockDataSource = MockDataSource() + + let picker = LinkPaymentMethodPicker() + picker.dataSource = mockDataSource + picker.supportedPaymentMethodTypes = [.card] + picker.layoutSubviews() + picker.setExpanded(true, animated: false) + + verify(picker) + } + + func testEmpty() { + let mockDataSource = MockDataSource(empty: true) + + let picker = LinkPaymentMethodPicker() + picker.dataSource = mockDataSource + picker.layoutSubviews() + + verify(picker) + } + + func verify( + _ view: UIView, + identifier: String? = nil, + file: StaticString = #filePath, + line: UInt = #line + ) { + view.autosizeHeight(width: 335) + view.backgroundColor = .white + STPSnapshotVerifyView(view, identifier: identifier, file: file, line: line) + } + +} + +extension LinkPaymentMethodPickerSnapshotTests { + + fileprivate final class MockDataSource: LinkPaymentMethodPickerDataSource { + let paymentMethods: [ConsumerPaymentDetails] + + init( + empty: Bool = false + ) { + self.paymentMethods = empty ? [] : LinkStubs.paymentMethods() + } + + func numberOfPaymentMethods(in picker: LinkPaymentMethodPicker) -> Int { + return paymentMethods.count + } + + func paymentPicker( + _ picker: LinkPaymentMethodPicker, + paymentMethodAt index: Int + ) -> ConsumerPaymentDetails { + return paymentMethods[index] + } + } + +} diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkVerificationViewSnapshotTests.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkVerificationViewSnapshotTests.swift new file mode 100644 index 00000000000..2763e508613 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkVerificationViewSnapshotTests.swift @@ -0,0 +1,77 @@ +// +// LinkVerificationViewSnapshotTests.swift +// StripeiOS Tests +// +// Created by Ramon Torres on 12/7/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import StripeCoreTestUtils +import UIKit + +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class LinkVerificationViewSnapshotTests: STPSnapshotTestCase { + + func testModal() { + let sut = makeSUT(mode: .modal) + verify(sut) + } + + func testModalWithErrorMessage() { + let sut = makeSUT(mode: .modal) + sut.errorMessage = "The provided verification code has expired." + verify(sut) + } + + func testInlineLogin() { + let sut = makeSUT(mode: .inlineLogin) + verify(sut) + } + + func testEmbedded() { + let sut = makeSUT(mode: .embedded) + verify(sut) + } + + func verify( + _ view: LinkVerificationView, + identifier: String? = nil, + file: StaticString = #filePath, + line: UInt = #line + ) { + view.autosizeHeight(width: 340) + STPSnapshotVerifyView(view, identifier: identifier, file: file, line: line) + } + +} + +extension LinkVerificationViewSnapshotTests { + + struct LinkAccountStub: PaymentSheetLinkAccountInfoProtocol { + let email: String + let redactedPhoneNumber: String? + let isRegistered: Bool + let isLoggedIn: Bool + } + + func makeSUT(mode: LinkVerificationView.Mode) -> LinkVerificationView { + let sut = LinkVerificationView( + mode: mode, + linkAccount: LinkAccountStub( + email: "user@example.com", + redactedPhoneNumber: "+1********55", + isRegistered: true, + isLoggedIn: false + ) + ) + + sut.tintColor = .linkBrand + + return sut + } + +} diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkCardEditElementSnapshotTests/testDefault@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkCardEditElementSnapshotTests/testDefault@3x.png new file mode 100644 index 00000000000..414b5cee83e Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkCardEditElementSnapshotTests/testDefault@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkCardEditElementSnapshotTests/testNonDefault@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkCardEditElementSnapshotTests/testNonDefault@3x.png new file mode 100644 index 00000000000..3cacc7b7113 Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkCardEditElementSnapshotTests/testNonDefault@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkPaymentMethodPickerSnapshotTests/testEmpty@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkPaymentMethodPickerSnapshotTests/testEmpty@3x.png new file mode 100644 index 00000000000..074be8faff1 Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkPaymentMethodPickerSnapshotTests/testEmpty@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkPaymentMethodPickerSnapshotTests/testExpanded@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkPaymentMethodPickerSnapshotTests/testExpanded@3x.png new file mode 100644 index 00000000000..34ca7b2b625 Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkPaymentMethodPickerSnapshotTests/testExpanded@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkPaymentMethodPickerSnapshotTests/testNormal_First_Option@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkPaymentMethodPickerSnapshotTests/testNormal_First_Option@3x.png new file mode 100644 index 00000000000..31861aa3354 Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkPaymentMethodPickerSnapshotTests/testNormal_First_Option@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkPaymentMethodPickerSnapshotTests/testNormal_Second_Option@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkPaymentMethodPickerSnapshotTests/testNormal_Second_Option@3x.png new file mode 100644 index 00000000000..50b5afa1ed5 Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkPaymentMethodPickerSnapshotTests/testNormal_Second_Option@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkPaymentMethodPickerSnapshotTests/testUnsupportedBankAccount@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkPaymentMethodPickerSnapshotTests/testUnsupportedBankAccount@3x.png new file mode 100644 index 00000000000..34ca7b2b625 Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkPaymentMethodPickerSnapshotTests/testUnsupportedBankAccount@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkVerificationViewSnapshotTests/testEmbedded@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkVerificationViewSnapshotTests/testEmbedded@3x.png new file mode 100644 index 00000000000..73994a01b5c Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkVerificationViewSnapshotTests/testEmbedded@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkVerificationViewSnapshotTests/testInlineLogin@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkVerificationViewSnapshotTests/testInlineLogin@3x.png new file mode 100644 index 00000000000..d5a1537b937 Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkVerificationViewSnapshotTests/testInlineLogin@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkVerificationViewSnapshotTests/testModal@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkVerificationViewSnapshotTests/testModal@3x.png new file mode 100644 index 00000000000..d4c27462729 Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkVerificationViewSnapshotTests/testModal@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkVerificationViewSnapshotTests/testModalWithErrorMessage@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkVerificationViewSnapshotTests/testModalWithErrorMessage@3x.png new file mode 100644 index 00000000000..d4d60aa7522 Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkVerificationViewSnapshotTests/testModalWithErrorMessage@3x.png differ