Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/Registration/UserInterface/RegistrationPhoneNumberInputView.swift
1 views
//
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import SignalServiceKit
import SignalUI

protocol RegistrationPhoneNumberInputViewDelegate: AnyObject {
    func present(_ countryCodeViewController: CountryCodeViewController)
    func didChange()
    func didPressReturn()
}

class RegistrationPhoneNumberInputView: UIView {
    weak var delegate: RegistrationPhoneNumberInputViewDelegate?

    // We impose a limit on the number of digits. This is much higher than what a valid E164 allows
    // and is just here for safety.
    private let maxNationalNumberDigits = 50

    init(initialPhoneNumber: RegistrationPhoneNumber) {
        self.country = initialPhoneNumber.country

        super.init(frame: .zero)

        layoutMargins = .init(hMargin: 16, vMargin: 9)

        // Background
        let backgroundView = UIView()
        if #available(iOS 26, *) {
            backgroundView.cornerConfiguration = .capsule()
        } else {
            backgroundView.layer.cornerRadius = 10
        }
        backgroundView.backgroundColor = .Signal.secondaryBackground
        addSubview(backgroundView)
        backgroundView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            backgroundView.topAnchor.constraint(equalTo: topAnchor),
            backgroundView.leadingAnchor.constraint(equalTo: leadingAnchor),
            backgroundView.bottomAnchor.constraint(equalTo: bottomAnchor),
            backgroundView.trailingAnchor.constraint(equalTo: trailingAnchor),
        ])

        // Content view (horizontal stack).
        let dividerView = UIView()
        dividerView.backgroundColor = .Signal.secondaryLabel

        let stackView = UIStackView(arrangedSubviews: [countryCodeView, dividerView, nationalNumberView])
        stackView.axis = .horizontal
        stackView.alignment = .center
        stackView.distribution = .fill
        stackView.spacing = 16
        addSubview(stackView)
        stackView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            dividerView.widthAnchor.constraint(equalToConstant: .hairlineWidth),
            dividerView.heightAnchor.constraint(equalTo: stackView.heightAnchor),

            stackView.heightAnchor.constraint(greaterThanOrEqualToConstant: 32),
            stackView.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor),
            stackView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor),
            stackView.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor),
            stackView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor),
        ])

        nationalNumberView.text = formatNationalNumber(input: initialPhoneNumber.nationalNumber)
        update()
    }

    @available(*, unavailable, message: "use other constructor")
    required init(coder: NSCoder) {
        owsFail("init(coder:) has not been implemented")
    }

    // MARK: - Data

    private(set) var country: PhoneNumberCountry {
        didSet { update() }
    }

    var nationalNumber: String { nationalNumberView.text?.asciiDigitsOnly ?? "" }

    var phoneNumber: RegistrationPhoneNumber {
        return RegistrationPhoneNumber(country: country, nationalNumber: nationalNumber)
    }

    var isEnabled: Bool = true {
        didSet {
            if !isEnabled {
                nationalNumberView.resignFirstResponder()
            }
            update()
        }
    }

    // MARK: - Rendering

    private lazy var countryCodeLabel: UILabel = {
        let result = UILabel()
        result.font = .dynamicTypeBodyClamped
        result.textAlignment = .center
        result.textColor = .Signal.label
        result.setCompressionResistanceHigh()
        result.setContentHuggingHorizontalHigh()
        return result
    }()

    private lazy var countryCodeView: UIView = {
        let container = UIView.container()

        container.addSubview(countryCodeLabel)
        countryCodeLabel.translatesAutoresizingMaskIntoConstraints = false

        var chevronIcon = UIImageView(image: UIImage(imageLiteralResourceName: "chevron-down-extra-small"))
        chevronIcon.tintColor = .Signal.secondaryLabel
        container.addSubview(chevronIcon)
        chevronIcon.translatesAutoresizingMaskIntoConstraints = false

        NSLayoutConstraint.activate([
            countryCodeLabel.topAnchor.constraint(greaterThanOrEqualTo: container.topAnchor),
            countryCodeLabel.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 4),
            countryCodeLabel.centerYAnchor.constraint(equalTo: container.centerYAnchor),

            chevronIcon.widthAnchor.constraint(equalToConstant: 12),
            chevronIcon.heightAnchor.constraint(equalToConstant: 12),
            chevronIcon.leadingAnchor.constraint(equalTo: countryCodeLabel.trailingAnchor, constant: 9),
            chevronIcon.centerYAnchor.constraint(equalTo: container.centerYAnchor),
            chevronIcon.trailingAnchor.constraint(equalTo: container.trailingAnchor),
        ])
        container.isUserInteractionEnabled = true
        container.addGestureRecognizer(UITapGestureRecognizer(
            target: self,
            action: #selector(didTapCountryCode),
        ))

        container.isAccessibilityElement = true
        container.accessibilityTraits = .button
        container.accessibilityIdentifier = "registration.phonenumber.countryCode"
        container.accessibilityLabel = OWSLocalizedString(
            "REGISTRATION_DEFAULT_COUNTRY_NAME",
            comment: "Label for the country code field",
        )

        return container
    }()

    private lazy var nationalNumberView: UITextField = {
        let result = UITextField()
        result.font = .dynamicTypeBodyClamped
        result.textAlignment = .left
        result.textColor = .Signal.label
        result.textContentType = .telephoneNumber
        result.keyboardType = .phonePad
        result.placeholder = OWSLocalizedString(
            "ONBOARDING_PHONE_NUMBER_PLACEHOLDER",
            comment: "Placeholder string for phone number field during registration",
        )
        result.delegate = self
        return result
    }()

    private func update() {
        countryCodeLabel.text = country.plusPrefixedCallingCode
        countryCodeView.accessibilityValue = countryCodeLabel.text
        nationalNumberView.isEnabled = isEnabled
    }

    // MARK: - Events

    @objc
    private func didTapCountryCode(sender: UIGestureRecognizer) {
        guard isEnabled, sender.state == .recognized, let delegate else { return }

        let countryCodeViewController = CountryCodeViewController(delegate: self)
        countryCodeViewController.interfaceOrientationMask = UIDevice.current.isIPad ? .all : .portrait

        delegate.present(countryCodeViewController)
    }

    // MARK: - Responder pass-through

    override var isFirstResponder: Bool { nationalNumberView.isFirstResponder }

    override var canBecomeFirstResponder: Bool { nationalNumberView.canBecomeFirstResponder }

    @discardableResult
    override func becomeFirstResponder() -> Bool { nationalNumberView.becomeFirstResponder() }

    @discardableResult
    override func resignFirstResponder() -> Bool { nationalNumberView.resignFirstResponder() }
}

// MARK: - UITextFieldDelegate

extension RegistrationPhoneNumberInputView: UITextFieldDelegate {
    func textField(
        _ textField: UITextField,
        shouldChangeCharactersIn range: NSRange,
        replacementString: String,
    ) -> Bool {
        let wasEmpty = textField.text.isEmptyOrNil
        var replacementString = replacementString

        if
            textField.text.isEmptyOrNil,
            let fullE164 = E164(replacementString.removeCharacters(characterSet: CharacterSet(charactersIn: " -()"))),
            let phoneNumber = RegistrationPhoneNumberParser(phoneNumberUtil: SSKEnvironment.shared.phoneNumberUtilRef).parseE164(fullE164)
        {
            // If we got a full e164, it was probably from system autofill.
            // Split out the country code portion.
            self.country = phoneNumber.country
            replacementString = phoneNumber.nationalNumber
        }

        let oldValue = textField.text!

        let result = FormattedNumberField.textField(
            textField,
            shouldChangeCharactersIn: range,
            replacementString: replacementString,
            allowedCharacters: .numbers,
            maxCharacters: maxNationalNumberDigits,
            format: formatNationalNumber,
        )

        if wasEmpty {
            DispatchQueue.main.async {
                // Move the cursor back to the end.
                textField.selectedTextRange = textField.textRange(
                    from: textField.endOfDocument,
                    to: textField.endOfDocument,
                )
            }
        }

        let newValue = textField.text!

        if newValue != oldValue {
            delegate?.didChange()
        }

        return result
    }

    func textFieldShouldReturn(_ textField: UITextField) -> Bool {
        delegate?.didPressReturn()
        return false
    }

    private func formatNationalNumber(input: String) -> String {
        return PhoneNumber.bestEffortFormatPartialUserSpecifiedTextToLookLikeAPhoneNumber(input, plusPrefixedCallingCode: country.plusPrefixedCallingCode)
    }
}

// MARK: - CountryCodeViewControllerDelegate

extension RegistrationPhoneNumberInputView: CountryCodeViewControllerDelegate {
    func countryCodeViewController(
        _ vc: CountryCodeViewController,
        didSelectCountry country: PhoneNumberCountry,
    ) {
        self.country = country

        nationalNumberView.text = PhoneNumber.bestEffortFormatPartialUserSpecifiedTextToLookLikeAPhoneNumber(nationalNumber, plusPrefixedCallingCode: country.plusPrefixedCallingCode)

        delegate?.didChange()
    }
}