Signal-iOS/Signal/Registration/UserInterface/RegistrationPhoneNumberViewController.swift
Igor Solomennikov aa67a8d9c9
Update reg phone input screen for iOS 26.
• added support for dynamic colors
• capsule shape for phone input field on iOS 26
• simplified layout code
• Next button is now always visible, but can be disabled
• "liquid glass" cancel button on iOS 26
2025-10-04 11:24:27 -07:00

407 lines
14 KiB
Swift

//
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalServiceKit
import SignalUI
// MARK: - RegistrationPhoneNumberPresenter
protocol RegistrationPhoneNumberPresenter: RegistrationMethodPresenter {
func goToNextStep(withE164: E164)
/// Completely exit registration. Not to be confused with `cancelChosenRestoreMethod`
/// which returns to the splash screen.
func exitRegistration()
}
// MARK: - RegistrationPhoneNumberViewController
class RegistrationPhoneNumberViewController: OWSViewController {
public init(
state: RegistrationPhoneNumberViewState.RegistrationMode,
presenter: RegistrationPhoneNumberPresenter
) {
self.state = state
self.presenter = presenter
self.phoneNumberInput = RegistrationPhoneNumberInputView(initialPhoneNumber: {
switch state {
case let .initialRegistration(state):
if let e164 = state.previouslyEnteredE164, let result = RegistrationPhoneNumberParser(phoneNumberUtil: SSKEnvironment.shared.phoneNumberUtilRef).parseE164(e164) {
return result
}
return RegistrationPhoneNumber(
country: .defaultValue,
nationalNumber: ""
)
case let .reregistration(state):
guard let result = RegistrationPhoneNumberParser(phoneNumberUtil: SSKEnvironment.shared.phoneNumberUtilRef).parseE164(state.e164) else {
owsFail("Could not parse re-registration E164")
}
return result
}
}())
super.init()
self.phoneNumberInput.delegate = self
}
public func updateState(_ state: RegistrationPhoneNumberViewState.RegistrationMode) {
self.state = state
}
@available(*, unavailable)
public override init() {
owsFail("This should not be called")
}
deinit {
nowTimer?.invalidate()
nowTimer = nil
}
// MARK: Internal state
private var state: RegistrationPhoneNumberViewState.RegistrationMode {
didSet { configureUI() }
}
private weak var presenter: RegistrationPhoneNumberPresenter?
private var now = Date() {
didSet { configureUI() }
}
private var nowTimer: Timer?
private var nationalNumber: String { phoneNumberInput.nationalNumber }
private var countryCode: String {
return phoneNumberInput.country.countryCode
}
private var localValidationError: RegistrationPhoneNumberViewState.ValidationError? {
didSet { configureUI() }
}
private var validationError: RegistrationPhoneNumberViewState.ValidationError? {
switch state {
case .initialRegistration(let initialRegistration):
return initialRegistration.validationError ?? localValidationError
case .reregistration(let reregistration):
return reregistration.validationError ?? localValidationError
}
}
private var canChangePhoneNumber: Bool {
switch state {
case .initialRegistration:
return true
case .reregistration:
return false
}
}
private func canSubmit(isBlockedByValidationError: Bool) -> Bool {
if phoneNumberInput.nationalNumber.isEmpty {
return false
}
switch state {
case .initialRegistration:
return !isBlockedByValidationError
case .reregistration:
return true
}
}
// MARK: Rendering
private lazy var contextButton: ContextMenuButton = {
let result = ContextMenuButton(empty: ())
result.setImage(Theme.iconImage(.buttonMore), for: .normal)
if #unavailable(iOS 26) {
result.tintColor = .Signal.accent
}
result.autoSetDimensions(to: .square(40))
return result
}()
private lazy var titleLabel: UILabel = {
let result = UILabel.titleLabelForRegistration(text: OWSLocalizedString(
"REGISTRATION_PHONE_NUMBER_TITLE",
comment: "During registration, users are asked to enter their phone number. This is the title on that screen."
))
result.accessibilityIdentifier = "registration.phonenumber.titleLabel"
return result
}()
private lazy var explanationLabel: UILabel = {
let result = UILabel.explanationLabelForRegistration(text: OWSLocalizedString(
"REGISTRATION_PHONE_NUMBER_SUBTITLE",
comment: "During registration, users are asked to enter their phone number. This is the subtitle on that screen, which gives users some instructions."
))
result.accessibilityIdentifier = "registration.phonenumber.explanationLabel"
return result
}()
private let phoneNumberInput: RegistrationPhoneNumberInputView
private lazy var validationWarningLabel: UILabel = {
let result = UILabel()
result.textColor = .ows_accentRed
result.numberOfLines = 0
result.font = .dynamicTypeSubheadlineClamped
result.accessibilityIdentifier = "registration.phonenumber.validationWarningLabel"
return result
}()
private lazy var cancelButton = UIButton(
configuration: .mediumSecondary(title: CommonStrings.cancelButton),
primaryAction: UIAction { [weak self] _ in
self?.phoneNumberInput.resignFirstResponder()
self?.presenter?.cancelChosenRestoreMethod()
}
)
public override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .Signal.background
navigationItem.leftBarButtonItem = UIBarButtonItem(
customView: contextButton,
accessibilityIdentifier: "registration.verificationCode.contextButton"
)
navigationItem.rightBarButtonItem = {
let barButtonItem = UIBarButtonItem(
title: CommonStrings.nextButton,
style: .done,
target: self,
action: #selector(didTapNext),
accessibilityIdentifier: "registration.phonenumber.nextButton"
)
barButtonItem.tintColor = .Signal.accent
return barButtonItem
}()
let stackView = UIStackView(arrangedSubviews: [
titleLabel,
explanationLabel,
phoneNumberInput,
validationWarningLabel,
])
stackView.spacing = 12
stackView.preservesSuperviewLayoutMargins = true
stackView.isLayoutMarginsRelativeArrangement = true
stackView.directionalLayoutMargins.top = 0
stackView.setCustomSpacing(24, after: explanationLabel)
stackView.axis = .vertical
view.addSubview(stackView)
stackView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(cancelButton)
cancelButton.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
stackView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
cancelButton.topAnchor.constraint(greaterThanOrEqualTo: stackView.bottomAnchor, constant: 24),
cancelButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
cancelButton.bottomAnchor.constraint(equalTo: keyboardLayoutGuide.topAnchor, constant: -24),
])
configureUI()
// We only need this timer if the user has been rate limited, but it's simpler to always
// start it.
nowTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
guard let self else { return }
self.now = Date()
}
}
public override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
Logger.info("")
let shouldBecomeFirstResponder: Bool = {
switch validationError {
case .rateLimited:
return false
case nil, .invalidInput, .invalidE164:
break
}
switch state {
case .reregistration:
return false
case .initialRegistration:
return true
}
}()
if shouldBecomeFirstResponder {
phoneNumberInput.becomeFirstResponder()
}
}
private func configureUI() {
var actions: [UIAction] = [
UIAction(
title: OWSLocalizedString(
"USE_PROXY_BUTTON",
comment: "Button to activate the signal proxy"
),
handler: { [weak self] _ in
guard let self else { return }
let vc = ProxySettingsViewController()
self.presentFormSheet(OWSNavigationController(rootViewController: vc), animated: true)
}
)
]
let canCancelChosenRegistrationMethod: Bool
let canExitRegistration: Bool
switch state {
case .initialRegistration(let subState):
canCancelChosenRegistrationMethod = true
canExitRegistration = subState.canExitRegistration
Logger.debug("initialRegistration")
case .reregistration(let subState):
canCancelChosenRegistrationMethod = false
canExitRegistration = subState.canExitRegistration
Logger.debug("reregistration")
}
cancelButton.isHidden = !canCancelChosenRegistrationMethod
cancelButton.isEnabled = canCancelChosenRegistrationMethod
if canExitRegistration {
actions.append(UIAction(
title: OWSLocalizedString(
"EXIT_REREGISTRATION",
comment: "Button to exit re-registration, shown in context menu."
),
handler: { [weak self] _ in
self?.presenter?.exitRegistration()
}
))
}
contextButton.setActions(actions: actions)
let now = Date()
let isBlockedByValidationError = { () -> Bool in
switch validationError {
case let .invalidInput(error):
return !error.canSubmit(countryCode: countryCode, nationalNumber: nationalNumber)
case let .invalidE164(error):
return !error.canSubmit(e164: parseE164())
case let .rateLimited(error):
return !error.canSubmit(e164: parseE164(), dateProvider: { now })
case nil:
return false
}
}()
navigationItem.rightBarButtonItem?.isEnabled = canSubmit(isBlockedByValidationError: isBlockedByValidationError)
phoneNumberInput.isEnabled = canChangePhoneNumber
// We always render the warning label but sometimes invisibly. This avoids UI jumpiness.
if isBlockedByValidationError, let validationError {
validationWarningLabel.alpha = 1
validationWarningLabel.text = validationError.warningLabelText(dateProvider: { now })
} else {
validationWarningLabel.alpha = 0
}
switch validationError {
case nil, .rateLimited:
break
case let .invalidInput(error):
showInvalidPhoneNumberAlertIfNecessary(for: .invalidInput(countryCode: error.invalidCountryCode, nationalNumber: error.invalidNationalNumber))
case let .invalidE164(error):
showInvalidPhoneNumberAlertIfNecessary(for: .invalidE164(error.invalidE164))
}
}
private enum InvalidNumberError: Equatable {
case invalidInput(countryCode: String, nationalNumber: String)
case invalidE164(E164)
}
private var previousInvalidNumberError: InvalidNumberError?
private func showInvalidPhoneNumberAlertIfNecessary(for invalidNumberError: InvalidNumberError) {
let shouldShowAlert = invalidNumberError != previousInvalidNumberError
if shouldShowAlert {
OWSActionSheets.showActionSheet(
title: OWSLocalizedString(
"REGISTRATION_VIEW_INVALID_PHONE_NUMBER_ALERT_TITLE",
comment: "Title of alert indicating that users needs to enter a valid phone number to register."
),
message: OWSLocalizedString(
"REGISTRATION_VIEW_INVALID_PHONE_NUMBER_ALERT_MESSAGE",
comment: "Message of alert indicating that users needs to enter a valid phone number to register."
)
)
}
previousInvalidNumberError = invalidNumberError
}
// MARK: Events
@objc
private func didTapNext() {
goToNextStep()
}
private func parseE164() -> E164? {
let phoneNumberUtil = SSKEnvironment.shared.phoneNumberUtilRef
return E164(phoneNumberUtil.parsePhoneNumber(countryCode: countryCode, nationalNumber: nationalNumber)?.e164)
}
private func goToNextStep() {
Logger.info("")
phoneNumberInput.resignFirstResponder()
guard let e164 = parseE164() else {
localValidationError = .invalidInput(.init(invalidCountryCode: countryCode, invalidNationalNumber: nationalNumber))
return
}
guard PhoneNumberValidator().isValidForRegistration(phoneNumber: e164) else {
localValidationError = .invalidE164(.init(invalidE164: e164))
return
}
localValidationError = nil
presentActionSheet(.forRegistrationVerificationConfirmation(
mode: .sms,
e164: e164.stringValue,
didConfirm: { [weak self] in self?.presenter?.goToNextStep(withE164: e164) },
didRequestEdit: { [weak self] in self?.phoneNumberInput.becomeFirstResponder() }
))
}
}
// MARK: - RegistrationPhoneNumberInputViewDelegate
extension RegistrationPhoneNumberViewController: RegistrationPhoneNumberInputViewDelegate {
func present(_ countryCodeViewController: CountryCodeViewController) {
let navController = OWSNavigationController(rootViewController: countryCodeViewController)
present(navController, animated: true)
}
func didChange() {
configureUI()
}
func didPressReturn() {
goToNextStep()
}
}