Signal-iOS/Signal/src/ViewControllers/Registration/Onboarding/OnboardingPhoneNumberViewController.swift
2022-03-10 16:03:56 -05:00

671 lines
28 KiB
Swift

//
// Copyright (c) 2022 Open Whisper Systems. All rights reserved.
//
import UIKit
@objc
public class RegistrationPhoneNumberViewController: OnboardingBaseViewController {
// MARK: - Properties
private lazy var retryAfterFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "m:ss"
formatter.timeZone = TimeZone(identifier: "UTC")!
return formatter
}()
private enum InlineError {
case invalidNumber
case rateLimit(expiration: Date)
}
private enum State {
case interactive
case verifying_animationDeferred
case verifying
}
private var phoneNumberError: InlineError? {
didSet { view.setNeedsLayout() }
}
private var isReregistering: Bool = false {
didSet { view.setNeedsLayout() }
}
private var state: State = .interactive {
didSet { view.setNeedsLayout() }
}
private var countryName: String { onboardingController.countryState.countryName }
private var callingCode: String { onboardingController.countryState.callingCode }
private var countryCode: String { onboardingController.countryState.countryCode }
private var phoneNumber: String? {
get { phoneNumberTextField.text }
set {
phoneNumberTextField.text = newValue
applyPhoneNumberFormatting()
}
}
// MARK: - Views
private var titleSpacer: UIView?
private let countryNameLabel: UILabel = {
let label = UILabel()
label.textColor = Theme.primaryTextColor
label.font = UIFont.ows_dynamicTypeBodyClamped
label.accessibilityIdentifier = "onboarding.phoneNumber." + "countryNameLabel"
return label
}()
private let countryChevron: UIImageView = {
let countryIconImage = CurrentAppContext().isRTL ? "small_chevron_left" : "small_chevron_right"
let countryIcon = UIImage(named: countryIconImage)
let imageView = UIImageView(image: countryIcon?.withRenderingMode(.alwaysTemplate))
imageView.tintColor = .ows_gray20
imageView.accessibilityIdentifier = "onboarding.phoneNumber." + "countryImageView"
return imageView
}()
private let callingCodeLabel: UILabel = {
let label = UILabel()
label.textColor = Theme.primaryTextColor
label.font = UIFont.ows_dynamicTypeBodyClamped
label.isUserInteractionEnabled = true
label.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(countryCodeTapped)))
label.accessibilityIdentifier = "onboarding.phoneNumber." + "callingCodeLabel"
return label
}()
private let phoneNumberTextField: UITextField = {
let field = UITextField()
field.font = UIFont.ows_dynamicTypeBodyClamped
field.textColor = Theme.primaryTextColor
field.textAlignment = .left
field.textContentType = .telephoneNumber
// There's a bug in iOS 13 where predictions aren't provided for .numberPad
// keyboard types. Leaving as number pad for now, but if we want to support
// autofill at the expense of a less appropriate keyboard, here's where it'd
// be done. See Wisors comment here:
// https://developer.apple.com/forums/thread/120703
if #available(iOS 14, *) {
field.keyboardType = .numberPad
} else if #available(iOS 13, *) {
field.keyboardType = .numberPad // .numbersAndPunctuation
} else {
field.keyboardType = .numberPad
}
field.placeholder = NSLocalizedString(
"ONBOARDING_PHONE_NUMBER_PLACEHOLDER",
comment: "Placeholder string for phone number field during registration")
field.accessibilityIdentifier = "onboarding.phoneNumber." + "phoneNumberTextField"
return field
}()
private var phoneStrokeNormal: UIView?
private var phoneStrokeError: UIView?
private let validationWarningLabel: UILabel = {
let label = UILabel()
label.textColor = .ows_accentRed
label.numberOfLines = 0
label.font = UIFont.ows_dynamicTypeSubheadlineClamped
label.accessibilityIdentifier = "onboarding.phoneNumber." + "validationWarningLabel"
return label
}()
lazy private var continueButton: OWSFlatButton = {
let button = self.primaryButton(title: CommonStrings.continueButton, selector: #selector(continuePressed))
button.accessibilityIdentifier = "onboarding.phoneNumber." + "continueButton"
return button
}()
private var progressSpinner: AnimatedProgressView = {
let spinner = AnimatedProgressView()
// We'll handle hiding animations ourselves
spinner.hidesWhenStopped = false
return spinner
}()
private var viewsToHideDuringVerification: [UIView]?
private var equalSpacerHeightConstraint: NSLayoutConstraint?
private var pinnedSpacerHeightConstraint: NSLayoutConstraint?
private var keyboardBottomConstraint: NSLayoutConstraint?
// MARK: - View Lifecycle
override public func loadView() {
view = UIView()
view.backgroundColor = Theme.backgroundColor
view.addSubview(primaryView)
primaryView.autoPinEdgesToSuperviewEdges()
// Setup subviews and stack views
let titleString = (Self.tsAccountManager.isReregistering
? NSLocalizedString(
"ONBOARDING_PHONE_NUMBER_TITLE_REREGISTERING",
comment: "Title of the 'onboarding phone number' view when the user is re-registering.")
: NSLocalizedString(
"ONBOARDING_PHONE_NUMBER_TITLE",
comment: "Title of the 'onboarding phone number' view."))
let titleLabel = self.createTitleLabel(text: titleString)
let countryRow = UIStackView(arrangedSubviews: [countryNameLabel, countryChevron])
countryRow.axis = .horizontal
countryRow.alignment = .center
countryRow.spacing = 10
countryRow.isUserInteractionEnabled = true
countryRow.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(countryRowTapped)))
_ = countryRow.addBottomStroke(color: .ows_gray20, strokeWidth: CGHairlineWidth())
countryChevron.setContentHuggingHorizontalHigh()
countryChevron.setCompressionResistanceHigh()
let phoneNumberRow = UIStackView(arrangedSubviews: [callingCodeLabel, phoneNumberTextField])
phoneNumberRow.axis = .horizontal
phoneNumberRow.alignment = .center
phoneNumberRow.spacing = 10
phoneStrokeNormal = phoneNumberRow.addBottomStroke(color: .ows_gray20, strokeWidth: CGHairlineWidth())
phoneStrokeError = phoneNumberRow.addBottomStroke(color: .ows_accentRed, strokeWidth: 1)
callingCodeLabel.autoSetDimension(.width, toSize: 45, relation: .greaterThanOrEqual)
callingCodeLabel.setCompressionResistanceHigh()
callingCodeLabel.setContentHuggingHorizontalHigh()
let titleSpacer = SpacerView(preferredHeight: 4)
let phoneNumberSpacer = SpacerView(preferredHeight: 11)
let warningLabelSpacer = SpacerView(preferredHeight: 4)
let bottomSpacer = SpacerView(preferredHeight: 4)
self.titleSpacer = titleSpacer
let stackView = UIStackView(arrangedSubviews: [
titleLabel, titleSpacer,
countryRow, phoneNumberRow, phoneNumberSpacer,
validationWarningLabel, warningLabelSpacer,
OnboardingBaseViewController.horizontallyWrap(primaryButton: continueButton), bottomSpacer
])
stackView.axis = .vertical
stackView.alignment = .fill
primaryView.addSubview(stackView)
primaryView.addSubview(progressSpinner)
viewsToHideDuringVerification = [countryRow, phoneNumberRow, validationWarningLabel]
// Here comes a bunch of autolayout prioritization to make sure we can fit on an iPhone 5s/SE
// It's complicated, but there are a few rules that help here:
// - First, set required constraints on everything that's *critical* for usability
// - Next, progressively add non-required constraints that are nice to have, but not critical.
// - Finally, pick one and only one view in the stack and set its contentHugging explicitly low
//
// - Non-required constraints should each have a unique priority. This is important to resolve
// autolayout ambiguity e.g. I have 10pts of extra space, and two equally weighted constraints
// that both consume 8pts. What do I satisfy?
// - Every view should have an intrinsicContentSize. Content Hugging and Content Compression
// don't mean much without a content size.
stackView.autoPinEdge(toSuperviewSafeArea: .top, withInset: 0, relation: .greaterThanOrEqual)
stackView.autoPinEdge(toSuperviewMargin: .top).priority = .defaultHigh
stackView.autoPinWidthToSuperviewMargins()
keyboardBottomConstraint = autoPinView(toBottomOfViewControllerOrKeyboard: stackView, avoidNotch: true)
progressSpinner.autoCenterInSuperview()
// For when things get *really* cramped, here's what's required:
[titleLabel,
countryNameLabel,
countryChevron,
callingCodeLabel,
phoneNumberTextField,
continueButton].forEach { $0.setCompressionResistanceVerticalHigh() }
equalSpacerHeightConstraint = titleSpacer.autoMatch(.height, to: .height, of: warningLabelSpacer)
pinnedSpacerHeightConstraint = titleSpacer.autoSetDimension(.height, toSize: 0)
pinnedSpacerHeightConstraint?.isActive = false
// Views should ideally have a minimum amount of padding, but it's less required. In preferred order:
bottomSpacer.setContentCompressionResistancePriority(.required - 10, for: .vertical)
phoneNumberRow.autoSetDimension(.height, toSize: 50).priority = .required - 20
countryRow.autoSetDimension(.height, toSize: 50).priority = .required - 30
titleSpacer.setContentCompressionResistancePriority(.required - 40, for: .vertical)
warningLabelSpacer.setContentCompressionResistancePriority(.required - 50, for: .vertical)
// Ideally we'll try and satisfy these
phoneNumberSpacer.setContentCompressionResistancePriority(.defaultHigh, for: .vertical)
bottomSpacer.autoSetDimension(.height, toSize: 16, relation: .greaterThanOrEqual).priority = .defaultHigh
// If we're flush with space, bump up the keyboard spacer to the bottom layout margins
bottomSpacer.autoSetDimension(.height, toSize: primaryLayoutMargins.bottom).priority = .defaultLow
updateValidationWarningLabelCompressionResistance()
// And if we have so much space we don't know what to do with it, grow the space between
// the warning label and the continue button. Usually the top space will grow along with
// it because of the equal spacing constraint
warningLabelSpacer.setContentHuggingPriority(.init(100), for: .vertical)
titleLabel.accessibilityIdentifier = "onboarding.phoneNumber." + "titleLabel"
phoneNumberRow.accessibilityIdentifier = "onboarding.phoneNumber." + "phoneNumberRow"
countryRow.accessibilityIdentifier = "onboarding.phoneNumber." + "countryRow"
}
public override func viewDidLoad() {
super.viewDidLoad()
phoneNumberTextField.delegate = self
phoneNumberTextField.addTarget(self, action: #selector(textFieldDidChange(_:)), for: .editingChanged)
populateDefaults()
}
var isAppearing = false
public override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
shouldIgnoreKeyboardChanges = false
isAppearing = true
updateViewState(animated: false)
}
public override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
phoneNumberTextField.becomeFirstResponder()
}
public override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
shouldIgnoreKeyboardChanges = true
}
public override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
updateViewState(animated: !isAppearing)
isAppearing = false
}
public override func updateBottomLayoutConstraint(fromInset before: CGFloat, toInset after: CGFloat) {
var needsLayout = false
let isDismissing = (after == 0)
if isDismissing, equalSpacerHeightConstraint?.isActive == true {
pinnedSpacerHeightConstraint?.constant = titleSpacer?.height ?? 0
equalSpacerHeightConstraint?.isActive = false
pinnedSpacerHeightConstraint?.isActive = true
needsLayout = true
}
// Ignore any minor decreases in height. We want to grow to accomodate the
// QuickType bar, but shrinking in response to its dismissal is a bit much.
let isKeyboardGrowing = after > -(keyboardBottomConstraint?.constant ?? 0.0)
let isSignificantlyShrinking = ((before - after) / UIScreen.main.bounds.height) > 0.1
if isKeyboardGrowing || isSignificantlyShrinking || isDismissing {
super.updateBottomLayoutConstraint(fromInset: before, toInset: after)
needsLayout = true
}
if !isDismissing, equalSpacerHeightConstraint?.isActive == false {
pinnedSpacerHeightConstraint?.isActive = false
equalSpacerHeightConstraint?.isActive = true
needsLayout = true
}
if needsLayout {
view.layoutIfNeeded()
}
}
// MARK: - View population
private func populateDefaults() {
if let reregistrationNumber = fetchReregistrationNumberIfAvailable() {
phoneNumber = reregistrationNumber
isReregistering = true
} else if let lastRegisteredPhoneNumber = OnboardingController.lastRegisteredPhoneNumber(),
lastRegisteredPhoneNumber.count > 0 {
phoneNumber = lastRegisteredPhoneNumber
} else if let existingNumber = onboardingController.phoneNumber {
phoneNumber = existingNumber.userInput
}
}
private func fetchReregistrationNumberIfAvailable() -> String? {
// If re-registering, pre-populate the country (country code, calling code, country name)
// and phone number state.
guard tsAccountManager.isReregistering else {
return nil
}
guard let phoneNumberE164 = tsAccountManager.reregistrationPhoneNumber() else {
owsFailDebug("Could not resume re-registration; missing phone number.")
return nil
}
guard phoneNumberE164.count > 0 else {
owsFailDebug("Could not resume re-registration; invalid phoneNumberE164.")
return nil
}
guard let parsedPhoneNumber = PhoneNumber(fromE164: phoneNumberE164) else {
owsFailDebug("Could not resume re-registration; couldn't parse phoneNumberE164.")
return nil
}
guard let callingCodeNumeric = parsedPhoneNumber.getCountryCode() else {
owsFailDebug("Could not resume re-registration; missing callingCode.")
return nil
}
let callingCode = "\(COUNTRY_CODE_PREFIX)\(callingCodeNumeric)"
let countryCodes: [String] = phoneNumberUtil.countryCodes(fromCallingCode: callingCode)
guard let countryCode = countryCodes.first else {
owsFailDebug("Could not resume re-registration; unknown countryCode.")
return nil
}
guard let countryName = PhoneNumberUtil.countryName(fromCountryCode: countryCode) else {
owsFailDebug("Could not resume re-registration; unknown countryName.")
return nil
}
if !phoneNumberE164.hasPrefix(callingCode) {
owsFailDebug("Could not resume re-registration; non-matching calling code.")
return nil
}
let phoneNumberWithoutCallingCode = phoneNumberE164.substring(from: callingCode.count)
guard countryCode.count > 0 else {
owsFailDebug("Invalid country code.")
return nil
}
guard countryName.count > 0 else {
owsFailDebug("Invalid country name.")
return nil
}
guard callingCode.count > 0 else {
owsFailDebug("Invalid calling code.")
return nil
}
let countryState = RegistrationCountryState(countryName: countryName, callingCode: callingCode, countryCode: countryCode)
onboardingController.update(countryState: countryState)
return phoneNumberWithoutCallingCode
}
private func updateViewState(animated: Bool = true) {
AssertIsOnMainThread()
let showError = (phoneNumberError != nil)
let showSpinner: Bool
switch state {
case .interactive:
showSpinner = false
case .verifying_animationDeferred:
showSpinner = false
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250)) {
guard self.state == .verifying_animationDeferred else { return }
self.state = .verifying
}
case .verifying:
showSpinner = true
}
// Update non-animated properties immediately
countryNameLabel.text = countryName
callingCodeLabel.text = callingCode
updateValidationLabel()
phoneNumberTextField.isEnabled = !isReregistering
continueButton.setEnabled(state == .interactive)
if showSpinner, !progressSpinner.isAnimating {
progressSpinner.startAnimating()
} else if !showSpinner, progressSpinner.isAnimating {
progressSpinner.stopAnimatingImmediately()
}
let animationBlock = {
if showSpinner {
self.viewsToHideDuringVerification?.forEach { $0.alpha = 0 }
self.progressSpinner.alpha = 1
} else {
// Do this first, since there's overlap with the views below
self.viewsToHideDuringVerification?.forEach { $0.alpha = 1 }
self.progressSpinner.alpha = 0
self.phoneStrokeNormal?.alpha = showError ? 0 : 1
self.phoneStrokeError?.alpha = showError ? 1 : 0
self.validationWarningLabel.alpha = showError ? 1 : 0
self.updateValidationWarningLabelCompressionResistance()
self.primaryView.layoutIfNeeded()
}
}
if animated {
UIView.animate(withDuration: 0.25, delay: 0, options: .beginFromCurrentState, animations: animationBlock)
} else {
animationBlock()
}
}
private func updateValidationLabel() {
switch phoneNumberError {
case .invalidNumber:
validationWarningLabel.text = NSLocalizedString(
"ONBOARDING_PHONE_NUMBER_VALIDATION_WARNING",
comment: "Label indicating that the phone number is invalid in the 'onboarding phone number' view.")
case let .rateLimit(expiration: expirationDate) where expirationDate > Date():
let rateLimitFormat = NSLocalizedString(
"ONBOARDING_PHONE_NUMBER_RATE_LIMIT_WARNING_FORMAT",
comment: "Label indicating that registration has been ratelimited. Embeds {{remaining time string}}.")
let timeRemaining = expirationDate.timeIntervalSinceNow
let durationString = retryAfterFormatter.string(from: Date(timeIntervalSinceReferenceDate: timeRemaining))
validationWarningLabel.text = String(format: rateLimitFormat, durationString)
// Repeatedly update the validation warning label. Eventually the where condition above
// (expirationDate > Date()) will no longer hold and the error will be cleared
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { [weak self] in
self?.updateValidationLabel()
}
case let .rateLimit(expiration: expirationDate) where expirationDate <= Date():
// Rate limit expiration is in the past, clear the error
phoneNumberError = nil
default:
// Both of our text blobs are about the same size. Ideally we don't want to move views when the error appears
// So we pre-populate with filler text to try and maintain an approximately consistent intrinsic content size
// If there's no error, the view will be hidden and compression resistance reduced.
validationWarningLabel.text = NSLocalizedString(
"ONBOARDING_PHONE_NUMBER_VALIDATION_WARNING",
comment: "Label indicating that the phone number is invalid in the 'onboarding phone number' view.")
}
}
private func updateValidationWarningLabelCompressionResistance() {
// validationWarningLabel's compression resistance will toggle between high priority and low priority
// as it becomes visible. Ideally, we want to make space for it to avoid layout adjustments when
// showing the error. But when space constrained, it's the first thing we'll give up.
let desiredResistance: UILayoutPriority = phoneNumberError != nil ? .required - 1 : .defaultLow - 1
self.validationWarningLabel.setContentCompressionResistancePriority(desiredResistance, for: .vertical)
}
private func applyPhoneNumberFormatting() {
AssertIsOnMainThread()
ViewControllerUtils.reformatPhoneNumber(phoneNumberTextField, callingCode: callingCode)
}
// MARK: - Events
@objc func countryRowTapped(sender: UIGestureRecognizer) {
guard sender.state == .recognized else {
return
}
showCountryPicker()
}
@objc func countryCodeTapped(sender: UIGestureRecognizer) {
guard sender.state == .recognized else {
return
}
showCountryPicker()
}
@objc func continuePressed() {
Logger.info("")
parseAndTryToRegister()
}
// MARK: - Country Picker
private func showCountryPicker() {
guard !isReregistering else { return }
let countryCodeController = CountryCodeViewController()
countryCodeController.countryCodeDelegate = self
countryCodeController.interfaceOrientationMask = UIDevice.current.isIPad ? .all : .portrait
let navigationController = OWSNavigationController(rootViewController: countryCodeController)
self.present(navigationController, animated: true, completion: nil)
}
// MARK: - Register
private func parseAndTryToRegister() {
guard let phoneNumberText = phoneNumber?.ows_stripped(),
phoneNumberText.count > 0 else {
phoneNumberError = .invalidNumber
OWSActionSheets.showActionSheet(
title: NSLocalizedString(
"REGISTRATION_VIEW_NO_PHONE_NUMBER_ALERT_TITLE",
comment: "Title of alert indicating that users needs to enter a phone number to register."),
message: NSLocalizedString(
"REGISTRATION_VIEW_NO_PHONE_NUMBER_ALERT_MESSAGE",
comment: "Message of alert indicating that users needs to enter a phone number to register."))
return
}
let phoneNumber = "\(callingCode)\(phoneNumberText)"
guard let localNumber = PhoneNumber.tryParsePhoneNumber(fromUserSpecifiedText: phoneNumber),
localNumber.toE164().count > 0,
PhoneNumberValidator().isValidForRegistration(phoneNumber: localNumber) else {
phoneNumberError = .invalidNumber
OWSActionSheets.showActionSheet(
title: NSLocalizedString(
"REGISTRATION_VIEW_INVALID_PHONE_NUMBER_ALERT_TITLE",
comment: "Title of alert indicating that users needs to enter a valid phone number to register."),
message: NSLocalizedString(
"REGISTRATION_VIEW_INVALID_PHONE_NUMBER_ALERT_MESSAGE",
comment: "Message of alert indicating that users needs to enter a valid phone number to register."))
return
}
if Self.tsAccountManager.isReregistering {
self.requestVerification(with: RegistrationPhoneNumber(e164: localNumber.toE164(), userInput: phoneNumberText))
return
}
let formattedNumber = PhoneNumber.bestEffortLocalizedPhoneNumber(withE164: localNumber.toE164())
let progressViewFormat = NSLocalizedString(
"REGISTRATION_VIEW_PHONE_NUMBER_SPINNER_LABEL_FORMAT",
comment: "Label for the progress spinner shown during phone number registration. Embeds {{phone number}}.")
progressSpinner.loadingText = String(format: progressViewFormat, formattedNumber)
// Preemptively resign so we don't reacquire first responder status between the alert
// dismissal and the progress view animation
self.phoneNumberTextField.resignFirstResponder()
self.onboardingController.presentPhoneNumberConfirmationSheet(from: self, number: formattedNumber) { [weak self] shouldContinue in
guard let self = self else { return }
if shouldContinue {
self.requestVerification(with: RegistrationPhoneNumber(e164: localNumber.toE164(), userInput: phoneNumberText))
} else {
// User wants to edit, retake first responder
self.phoneNumberTextField.becomeFirstResponder()
}
}
}
private func requestVerification(with phoneNumber: RegistrationPhoneNumber) {
self.onboardingController.update(phoneNumber: phoneNumber)
self.verificationAnimation(shouldPlay: true)
self.onboardingController.requestVerification(fromViewController: self, isSMS: true) { [weak self] willDismiss, error in
guard let self = self else { return }
self.verificationAnimation(shouldPlay: false)
// If the onboarding controller is not transitioning away from us, retake first responder
guard !willDismiss else { return }
self.phoneNumberTextField.becomeFirstResponder()
if let error = error, error.httpStatusCode == 413 || error.httpStatusCode == 429 {
// If we're not handed a retry-after date directly from the server, either
// use the existing date we already have or construct a new date 5 min from now
let retryAfterDate = error.httpRetryAfterDate ?? {
if case let .rateLimit(existingRetryAfter) = self.phoneNumberError {
return existingRetryAfter
} else {
return Date(timeIntervalSinceNow: 300)
}
}()
self.phoneNumberError = .rateLimit(expiration: retryAfterDate)
// TODO: Once notification work is complete, uncomment this.
// self.notificationPresenter.notifyUserOfIncompleteRegistration(on: retryAfterDate)
}
}
}
private func verificationAnimation(shouldPlay: Bool) {
if shouldPlay {
if state == .interactive {
state = .verifying_animationDeferred
}
} else {
state = .interactive
}
}
}
// MARK: -
extension RegistrationPhoneNumberViewController: UITextFieldDelegate {
public func textField(
_ textField: UITextField,
shouldChangeCharactersIn range: NSRange,
replacementString string: String) -> Bool {
if case .invalidNumber = phoneNumberError {
phoneNumberError = nil
}
// If ViewControllerUtils applied the edit on our behalf, inform UIKit
// so the edit isn't applied twice.
return ViewControllerUtils.phoneNumber(
textField,
shouldChangeCharactersIn: range,
replacementString: string,
callingCode: callingCode)
}
@objc
private func textFieldDidChange(_ textField: UITextField) {
applyPhoneNumberFormatting()
}
public func textFieldShouldReturn(_ textField: UITextField) -> Bool {
parseAndTryToRegister()
return false
}
}
// MARK: -
extension RegistrationPhoneNumberViewController: CountryCodeViewControllerDelegate {
public func countryCodeViewController(_ vc: CountryCodeViewController,
didSelectCountry countryState: RegistrationCountryState) {
onboardingController.update(countryState: countryState)
updateViewState(animated: false)
}
}