Path: blob/main/Signal/Registration/UserInterface/RegistrationViewUtil.swift
1 views
//
// 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
result.accessibilityTraits = [.staticText, .header]
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),
])
button.accessibilityLabel = contentConfiguration.title
button.accessibilityHint = contentConfiguration.subtitle
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.nonPluralLocalizedStringWithFormat(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
}
}