// // Copyright 2023 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import Foundation import SignalServiceKit import SignalUI // MARK: - Strings extension String { var e164FormattedAsPhoneNumberWithoutBreaks: String { let formatted = PhoneNumber.bestEffortLocalizedPhoneNumber(e164: self) return formatted.replacingOccurrences(of: " ", with: "\u{00a0}") } } // MARK: - Layout margins extension NSDirectionalEdgeInsets { static func layoutMarginsForRegistration( _ horizontalSizeClass: UIUserInterfaceSizeClass ) -> NSDirectionalEdgeInsets { switch horizontalSizeClass { case .regular: return NSDirectionalEdgeInsets(top: 0, leading: 112, bottom: 112, trailing: 112) case .unspecified, .compact: fallthrough @unknown default: return NSDirectionalEdgeInsets(top: 0, leading: 32, bottom: 32, trailing: 32) } } } // MARK: - Labels extension UILabel { static func titleLabelForRegistration(text: String) -> UILabel { let result = UILabel() result.text = text result.textColor = .Signal.label result.font = UIFont.dynamicTypeTitle1Clamped.semibold() result.numberOfLines = 0 result.lineBreakMode = .byWordWrapping result.textAlignment = .center return result } static func explanationLabelForRegistration(text: String) -> UILabel { let result = UILabel() result.textColor = .Signal.secondaryLabel result.font = .dynamicTypeBodyClamped result.text = text result.numberOfLines = 0 result.textAlignment = .center result.lineBreakMode = .byWordWrapping return result } } // MARK: - Buttons extension UIButton { class func registrationChoiceButton( title: String, subtitle: String, iconName: String, primaryAction: UIAction? = nil ) -> Self { let button = UIButton(configuration: .gray(), primaryAction: primaryAction) // Set up button background. if #available(iOS 26, *) { button.configuration?.background.cornerRadius = 26 } else { button.configuration?.background.cornerRadius = 8 } button.configuration?.baseBackgroundColor = .Signal.quaternaryFill // Add content view. let contentConfiguration = RegistrationChoiceButtonContentConfiguration( title: title, subtitle: subtitle, iconName: iconName ) let contentView = contentConfiguration.makeContentView() button.addSubview(contentView) contentView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ contentView.leadingAnchor.constraint(equalTo: button.leadingAnchor), contentView.topAnchor.constraint(equalTo: button.topAnchor), contentView.trailingAnchor.constraint(equalTo: button.trailingAnchor), contentView.bottomAnchor.constraint(equalTo: button.bottomAnchor) ]) return button as! Self } } private struct RegistrationChoiceButtonContentConfiguration: UIContentConfiguration { var title: String var subtitle: String var iconName: String var imageSize: CGFloat? func makeContentView() -> UIView & UIContentView { RegistrationChoiceButtonContentView(configuration: self) } func updated(for state: UIConfigurationState) -> RegistrationChoiceButtonContentConfiguration { // Looks the same. self } } private class RegistrationChoiceButtonContentView: UIView, UIContentView { private let iconView = UIImageView() private let titleLabel = UILabel() private let subtitleLabel = UILabel() private let disclosureView = UILabel() init(configuration: RegistrationChoiceButtonContentConfiguration) { super.init(frame: .zero) setupViews() self.configuration = configuration } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private var currentConfiguration: RegistrationChoiceButtonContentConfiguration! var configuration: UIContentConfiguration { get { currentConfiguration } set { guard let config = newValue as? RegistrationChoiceButtonContentConfiguration else { return } currentConfiguration = config apply(configuration: config) } } func apply(configuration: RegistrationChoiceButtonContentConfiguration) { titleLabel.text = configuration.title subtitleLabel.text = configuration.subtitle iconView.image = UIImage(named: configuration.iconName)?.withRenderingMode(.alwaysTemplate) iconView.sizeToFit() } private func setupViews() { isUserInteractionEnabled = false // Icon let iconContainer = UIView() iconView.contentMode = .scaleAspectFit iconView.tintColor = .Signal.ultramarine iconContainer.addSubview(iconView) iconContainer.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ iconContainer.widthAnchor.constraint(equalToConstant: 48), iconContainer.heightAnchor.constraint(equalToConstant: 48), iconView.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor), iconView.centerYAnchor.constraint(equalTo: iconContainer.centerYAnchor), iconView.trailingAnchor.constraint(greaterThanOrEqualTo: iconContainer.trailingAnchor), iconView.topAnchor.constraint(greaterThanOrEqualTo: iconContainer.topAnchor), ]) // Labels titleLabel.font = .dynamicTypeHeadline titleLabel.textColor = .Signal.label titleLabel.numberOfLines = 0 titleLabel.lineBreakMode = .byWordWrapping subtitleLabel.font = .dynamicTypeFootnote subtitleLabel.textColor = .Signal.secondaryLabel subtitleLabel.numberOfLines = 0 subtitleLabel.lineBreakMode = .byWordWrapping let vStack = UIStackView(arrangedSubviews: [ titleLabel, subtitleLabel, ]) vStack.axis = .vertical vStack.spacing = 2 // Disclosure Indicator let disclosureView = UIImageView(image: UIImage(imageLiteralResourceName: "chevron-right-20")) disclosureView.tintColor = .Signal.tertiaryLabel disclosureView.translatesAutoresizingMaskIntoConstraints = false // This must be unnecessary but I've observed that without this constraint // UIKit does not split `titleLabel` in two lines when it should, clipping it instead. disclosureView.widthAnchor.constraint(equalToConstant: 20).isActive = true let hStack = UIStackView(arrangedSubviews: [ iconContainer, vStack, disclosureView, ]) hStack.setCustomSpacing(20, after: vStack) hStack.alignment = .center hStack.axis = .horizontal hStack.spacing = 12 hStack.isLayoutMarginsRelativeArrangement = true hStack.directionalLayoutMargins = NSDirectionalEdgeInsets(top: 21, leading: 12, bottom: 21, trailing: 16) hStack.isUserInteractionEnabled = false addSubview(hStack) hStack.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ hStack.topAnchor.constraint(equalTo: topAnchor), hStack.leadingAnchor.constraint(equalTo: leadingAnchor), hStack.bottomAnchor.constraint(equalTo: bottomAnchor), hStack.trailingAnchor.constraint(equalTo: trailingAnchor), ]) } } // MARK: - Action sheets extension ActionSheetController { enum RegistrationVerificationConfirmationMode { case sms case voice } static func forRegistrationVerificationConfirmation( mode: RegistrationVerificationConfirmationMode, e164: String, didConfirm: @escaping () -> Void, didRequestEdit: @escaping () -> Void ) -> ActionSheetController { let message: String switch mode { case .sms: message = OWSLocalizedString( "REGISTRATION_VIEW_PHONE_NUMBER_CONFIRMATION_ALERT_MESSAGE", comment: "Message for confirmation alert during phone number registration." ) case .voice: message = OWSLocalizedString( "REGISTRATION_PHONE_NUMBER_VOICE_CODE_ALERT_MESSAGE", comment: "Message for confirmation alert when requesting a voice code during phone number registration." ) } let result = ActionSheetController( title: { let format = OWSLocalizedString( "REGISTRATION_VIEW_PHONE_NUMBER_CONFIRMATION_ALERT_TITLE_FORMAT", comment: "Title for confirmation alert during phone number registration. Embeds {{phone number}}." ) return String(format: format, e164.e164FormattedAsPhoneNumberWithoutBreaks) }(), message: message ) let confirmButtonTitle = CommonStrings.yesButton result.addAction(.init(title: confirmButtonTitle) { _ in didConfirm() }) let editButtonTitle = OWSLocalizedString( "REGISTRATION_VIEW_PHONE_NUMBER_CONFIRMATION_EDIT_BUTTON", comment: "A button allowing user to cancel registration and edit a phone number" ) result.addAction(.init(title: editButtonTitle) { _ in didRequestEdit() }) return result } } // MARK: - Alerts extension UIAlertController { static func registrationAppUpdateBanner() -> UIAlertController { let result = UIAlertController( title: OWSLocalizedString( "REGISTRATION_CANNOT_CONTINUE_WITHOUT_UPDATING_APP_TITLE", comment: "During (re)registration, users may need to update their app to continue. They'll be presented with an alert if this is the case, prompting them to update. This is the title on that alert." ), message: OWSLocalizedString( "REGISTRATION_CANNOT_CONTINUE_WITHOUT_UPDATING_APP_DESCRIPTION", comment: "During (re)registration, users may need to update their app to continue. They'll be presented with an alert if this is the case, prompting them to update. This is the description text on that alert." ), preferredStyle: .alert ) let updateAction = UIAlertAction( title: OWSLocalizedString( "REGISTRATION_CANNOT_CONTINUE_WITHOUT_UPDATING_APP_ACTION", comment: "During (re)registration, users may need to update their app to continue. They'll be presented with an alert if this is the case, prompting them to update. This is the action button on that alert." ), style: .default ) { _ in UIApplication.shared.open(TSConstants.appStoreUrl) } result.addAction(updateAction) result.preferredAction = updateAction return result } }